Pocket Deck

@itzchillv2.1.0

Use your phone as a trackpad, media remote, and clipboard bridge.

Files

Version 2.1.0 (current)

phone/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
  <meta name="theme-color" content="#0b0d0c">
  <title>Pocket Deck</title>
  <style>
    :root {
      color-scheme: dark;
      --bg: #0b0d0c;
      --panel: #131714;
      --panel-2: #181e1a;
      --line: #29322c;
      --text: #f4f1e8;
      --soft: #d8ddd3;
      --muted: #98a398;
      --accent: #c7f36b;
      --accent-ink: #11150d;
      --danger: #ff7b72;
      --radius: 16px;
      --pad: 18px;
    }

    * { box-sizing: border-box; }
    html, body { margin: 0; min-height: 100%; background: var(--bg); }
    body {
      color: var(--text);
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      overscroll-behavior: none;
    }

    button, input, textarea { font: inherit; }
    button { color: inherit; }
    .app {
      width: min(560px, 100%);
      margin: 0 auto;
      padding: max(14px, env(safe-area-inset-top)) 14px max(20px, env(safe-area-inset-bottom));
    }

    .top {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 4px 2px 14px;
    }
    .brand {
      display: flex;
      align-items: center;
      gap: 10px;
      min-width: 0;
    }
    .brand-mark {
      width: 26px;
      height: 26px;
      border-radius: 8px;
      background: var(--accent);
      position: relative;
      flex: 0 0 auto;
    }
    .brand-mark::after {
      content: "";
      position: absolute;
      inset: 7px;
      border-radius: 4px;
      background: var(--accent-ink);
    }
    .brand h1 {
      font-size: 15px;
      line-height: 1;
      letter-spacing: .12em;
      margin: 0;
      font-weight: 800;
    }
    .brand p {
      margin: 4px 0 0;
      font-size: 10px;
      color: var(--muted);
      letter-spacing: .04em;
    }

    .status {
      display: flex;
      align-items: center;
      gap: 7px;
      border: 1px solid var(--line);
      background: var(--panel);
      color: var(--muted);
      border-radius: 999px;
      padding: 7px 10px;
      font-size: 10px;
      letter-spacing: .05em;
      white-space: nowrap;
      cursor: pointer;
      user-select: none;
    }
    .status:active { border-color: var(--accent); }
    .status[data-state="locked"] .dot { background: #f2c66d; }
    .dot {
      width: 7px;
      height: 7px;
      border-radius: 50%;
      background: var(--danger);
    }
    .status[data-state="open"] .dot { background: var(--accent); }
    .status[data-state="connecting"] .dot { background: #f2c66d; }
    .icon-btn {
      width: 36px;
      height: 36px;
      border: 1px solid var(--line);
      border-radius: 11px;
      background: var(--panel);
      cursor: pointer;
      display: inline-grid;
      place-items: center;
      padding: 0;
    }
    .icon-btn:active { transform: translateY(1px); }

    .card {
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: var(--radius);
      padding: 14px;
      margin-bottom: 10px;
    }
    .card-head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      margin-bottom: 10px;
    }
    .eyebrow {
      font-size: 9px;
      letter-spacing: .14em;
      font-weight: 800;
      color: var(--muted);
    }
    .tiny {
      font-size: 9px;
      color: var(--muted);
    }

    .trackpad {
      height: 214px;
      border-radius: 12px;
      border: 1px solid #344039;
      background:
        linear-gradient(var(--line) 1px, transparent 1px),
        linear-gradient(90deg, var(--line) 1px, transparent 1px),
        #0f1310;
      background-size: 32px 32px;
      position: relative;
      touch-action: none;
      cursor: crosshair;
      overflow: hidden;
      user-select: none;
      -webkit-touch-callout: none;
    }
    .trackpad::before {
      content: "";
      position: absolute;
      top: 12px;
      bottom: 24px;
      left: 70%;
      width: 1px;
      background: #2b362e;
      pointer-events: none;
    }
    .trackpad::after {
      content: "TAP · HOLD LEFT TO DRAG";
      position: absolute;
      left: 35%;
      top: 50%;
      transform: translate(-50%, -50%);
      width: 60%;
      text-align: center;
      font-size: 9px;
      letter-spacing: .12em;
      color: #5f6c62;
      pointer-events: none;
    }
    .trackpad.holding {
      border-color: var(--accent);
      box-shadow: inset 0 0 0 1px rgba(199, 243, 107, .35);
    }
    .trackpad.holding::after {
      content: "HOLDING LEFT · MOVE TO DRAG";
      color: var(--accent);
    }
    .trackpad:active { border-color: var(--accent); }
    .pad-zone {
      position: absolute;
      bottom: 8px;
      z-index: 1;
      color: #5f6c62;
      font-size: 8px;
      font-weight: 800;
      letter-spacing: .1em;
      pointer-events: none;
    }
    .pad-zone-left { left: 10px; }
    .pad-zone-right { right: 10px; }

    .pad-actions {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      gap: 8px;
      margin-top: 8px;
    }
    .btn {
      border: 1px solid var(--line);
      background: var(--panel-2);
      border-radius: 10px;
      min-height: 40px;
      padding: 8px 10px;
      font-size: 11px;
      font-weight: 700;
      letter-spacing: .04em;
      cursor: pointer;
    }
    .btn:active { transform: translateY(1px); border-color: var(--accent); }
    .btn.accent {
      background: var(--accent);
      color: var(--accent-ink);
      border-color: var(--accent);
    }
    .btn.ghost { background: transparent; }

    .media-grid {
      display: grid;
      grid-template-columns: repeat(6, 1fr);
      gap: 6px;
    }
    .media-grid .btn {
      min-height: 42px;
      padding: 7px 4px;
      font-size: 9px;
      letter-spacing: .02em;
    }

    .clipboard {
      display: grid;
      gap: 8px;
    }
    textarea {
      width: 100%;
      min-height: 88px;
      resize: vertical;
      background: #0f1310;
      border: 1px solid var(--line);
      color: var(--text);
      border-radius: 10px;
      padding: 10px;
      font-size: 13px;
      line-height: 1.4;
      outline: none;
    }
    textarea:focus { border-color: var(--accent); }
    .clip-actions {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      gap: 8px;
    }

    .market-btn { width: 100%; }
    .market-note { margin: 8px 0 0; line-height: 1.45; }

    .sheet {
      position: fixed;
      inset: 0;
      z-index: 20;
      background: rgba(0,0,0,.62);
      display: none;
      align-items: flex-end;
      justify-content: center;
      padding: 12px;
    }
    .sheet.open { display: flex; }
    .sheet-panel {
      width: min(520px, 100%);
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 18px;
      padding: 16px;
      padding-bottom: max(16px, env(safe-area-inset-bottom));
      box-shadow: 0 24px 80px rgba(0,0,0,.45);
    }
    .sheet-head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin-bottom: 14px;
    }
    .sheet-head h2 {
      margin: 0;
      font-size: 16px;
      letter-spacing: .02em;
    }
    .field { display: grid; gap: 6px; margin-bottom: 10px; }
    .field label {
      font-size: 9px;
      letter-spacing: .12em;
      color: var(--muted);
      font-weight: 800;
    }
    .field input {
      width: 100%;
      background: #0f1310;
      border: 1px solid var(--line);
      border-radius: 10px;
      color: var(--text);
      padding: 11px;
      outline: none;
      font-size: 14px;
    }
    .field input:focus { border-color: var(--accent); }
    .field .hint { font-size: 10px; color: var(--muted); line-height: 1.35; }
    .sheet-actions {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
      margin-top: 14px;
    }

    .toast {
      position: fixed;
      left: 50%;
      bottom: max(18px, env(safe-area-inset-bottom));
      transform: translate(-50%, 12px);
      max-width: min(440px, calc(100% - 28px));
      background: #1a211c;
      border: 1px solid #3a463d;
      color: var(--text);
      border-radius: 11px;
      padding: 10px 13px;
      font-size: 11px;
      line-height: 1.35;
      opacity: 0;
      pointer-events: none;
      transition: opacity .16s ease, transform .16s ease;
      z-index: 30;
    }
    .toast.show { opacity: 1; transform: translate(-50%, 0); }

    @media (min-width: 700px) {
      .app { padding-top: 28px; }
      .trackpad { height: 250px; }
    }
    @media (prefers-reduced-motion: reduce) {
      * { transition: none !important; }
    }
  </style>
</head>
<body>
  <div class="app">
    <header class="top">
      <div class="brand">
        <div class="brand-mark" aria-hidden="true"></div>
        <div>
          <h1>POCKET DECK</h1>
          <p>PHONE REMOTE FOR YOUR PC</p>
        </div>
      </div>
      <div style="display:flex;align-items:center;gap:7px">
        <div id="status" class="status" data-state="closed" role="button" tabindex="0" aria-label="Connection status. Tap to open connection settings."><i class="dot"></i><span>OFFLINE</span></div>
        <button id="settingsBtn" class="icon-btn" aria-label="Connection settings">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
            <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/>
            <path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.56V21h-4v-.09A1.7 1.7 0 0 0 8.97 19.35a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1.03H3v-4h.04A1.7 1.7 0 0 0 4.6 8.94a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 8.97 4.6 1.7 1.7 0 0 0 10 3.04V3h4v.04a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 8.94 1.7 1.7 0 0 0 20.96 10H21v4h-.04A1.7 1.7 0 0 0 19.4 15Z"/>
          </svg>
        </button>
      </div>
    </header>

    <section class="card">
      <div class="card-head">
        <span class="eyebrow">TRACKPAD</span>
        <span class="tiny">tap left · hold left to drag · tap right</span>
      </div>
      <div id="trackpad" class="trackpad" role="application" aria-label="Remote trackpad. Tap the left side to click, hold the left side to drag, or tap the right side to right-click.">
        <span class="pad-zone pad-zone-left">TAP / HOLD</span>
        <span class="pad-zone pad-zone-right">RIGHT CLICK</span>
      </div>
      <div class="pad-actions">
        <button class="btn" data-click="left">LEFT CLICK</button>
        <button class="btn" data-click="right">RIGHT CLICK</button>
        <button class="btn ghost" data-key="Enter">ENTER</button>
        <button class="btn ghost" data-scroll="up">SCROLL ↑</button>
        <button class="btn ghost" data-scroll="down">SCROLL ↓</button>
      </div>
    </section>

    <section class="card">
      <div class="card-head">
        <span class="eyebrow">MEDIA</span>
        <span class="tiny">system keys</span>
      </div>
      <div class="media-grid">
        <button class="btn" data-media="MediaPrev">PREV</button>
        <button class="btn accent" data-media="MediaPlay">PLAY</button>
        <button class="btn" data-media="MediaNext">NEXT</button>
        <button class="btn" data-media="VolumeDown">VOL−</button>
        <button class="btn" data-media="VolumeMute">MUTE</button>
        <button class="btn" data-media="VolumeUp">VOL+</button>
      </div>
    </section>

    <section class="card">
      <div class="card-head">
        <span class="eyebrow">CLIPBOARD</span>
        <span class="tiny">phone ↔ PC</span>
      </div>
      <div class="clipboard">
        <textarea id="clipText" placeholder="Type or paste text here…" spellcheck="false"></textarea>
        <div class="clip-actions">
          <button id="sendClip" class="btn accent">SEND TO PC</button>
          <button id="pullClip" class="btn">PULL FROM PC</button>
          <button id="typeClip" class="btn">TYPE IT</button>
        </div>
      </div>
    </section>

    <section class="card">
      <div class="card-head">
        <span class="eyebrow">DISCOVER</span>
        <span class="tiny">Rebind Marketplace</span>
      </div>
      <button id="marketBtn" class="btn ghost market-btn">BROWSE REBIND MARKETPLACE</button>
      <p class="tiny market-note">Opens Rebind Marketplace on the PC. Nothing installs automatically.</p>
    </section>

    <p class="tiny" style="text-align:center;margin:16px 0 0;line-height:1.5">
      Local network only · no cloud · no phone app
    </p>
  </div>

  <div id="sheet" class="sheet" aria-hidden="true">
    <div class="sheet-panel" role="dialog" aria-modal="true" aria-labelledby="sheetTitle">
      <div class="sheet-head">
        <h2 id="sheetTitle">Connection</h2>
        <button id="closeSheet" class="icon-btn" aria-label="Close settings">×</button>
      </div>
      <div class="field">
        <label for="hostInput">PC address</label>
        <input id="hostInput" type="text" inputmode="decimal" autocomplete="off" placeholder="192.168.1.10">
        <div class="hint">Open this page from the PC’s HTTP address and this is filled automatically.</div>
      </div>
      <div class="field">
        <label for="portInput">WebSocket port</label>
        <input id="portInput" type="number" inputmode="numeric" min="1024" max="65535" placeholder="19881">
      </div>
      <div class="field">
        <label for="tokenInput">Access token</label>
        <input id="tokenInput" type="password" autocomplete="off" placeholder="From Rebind Options">
        <div class="hint">Set the same token in Rebind → Options → Pocket Deck. It stays on this phone.</div>
      </div>
      <div class="sheet-actions">
        <button id="saveSettings" class="btn accent">CONNECT</button>
        <button id="reconnectBtn" class="btn">RECONNECT</button>
      </div>
    </div>
  </div>

  <div id="toast" class="toast" role="status"></div>

  <script>
    (() => {
      const $ = (id) => document.getElementById(id);
      const statusEl = $("status");
      const trackpad = $("trackpad");
      const clipText = $("clipText");
      const toastEl = $("toast");
      const sheet = $("sheet");
      const hostInput = $("hostInput");
      const portInput = $("portInput");
      const tokenInput = $("tokenInput");

      const LS = {
        host: "pocketdeck.host",
        port: "pocketdeck.port",
        token: "pocketdeck.token",
      };

      // A one-time pairing link can carry the token in the URL fragment. The
      // fragment is not sent to the HTTP server, and it is removed after saving.
      const pairToken = (() => {
        const raw = location.hash.startsWith("#") ? location.hash.slice(1) : "";
        const value = new URLSearchParams(raw).get("token") || "";
        if (value) {
          try { localStorage.setItem(LS.token, value); } catch (_) {}
          history.replaceState(null, "", location.pathname + location.search);
        }
        return value;
      })();

      // When the page is served by the PC, its address is the authoritative
      // address. A saved host is only a fallback for a local file or an
      // offline copy, so an old IP cannot keep reconnecting to the wrong PC.
      const currentHost = location.protocol === "file:" ? "" : location.hostname;
      const inferredWsPort = location.port === "19880" ? 19881 : 0;

      const state = {
        ws: null,
        authed: false,
        connecting: false,
        retry: null,
        pending: new Map(),
        nextId: 2,
        host: currentHost || localStorage.getItem(LS.host) || "",
        port: inferredWsPort || Number(localStorage.getItem(LS.port) || 19881),
        token: pairToken || localStorage.getItem(LS.token) || "",
        lastWarnAt: 0,
        authNoticeShown: false,
      };

      hostInput.value = state.host;
      portInput.value = String(state.port);
      tokenInput.value = state.token;

      function setStatus(text, mode) {
        statusEl.dataset.state = mode;
        statusEl.querySelector("span").textContent = text;
      }

      let toastTimer = null;
      function toast(text) {
        toastEl.textContent = text;
        toastEl.classList.add("show");
        clearTimeout(toastTimer);
        toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2200);
      }

      function warnConnection(message) {
        const now = Date.now();
        if (now - state.lastWarnAt < 2500) return;
        state.lastWarnAt = now;
        toast(message);
      }

      if (!state.token) setStatus("SET TOKEN", "locked");

      function socketUrl() {
        return `ws://${state.host}:${state.port}`;
      }

      function connect() {
        if (state.ws) {
          try { state.ws.close(); } catch (_) {}
        }
        clearTimeout(state.retry);
        state.authed = false;

        if (!state.host || !state.token) {
          state.connecting = false;
          setStatus(state.token ? "SET ADDRESS" : "SET TOKEN", "locked");
          return;
        }

        state.connecting = true;
        setStatus("CONNECTING", "connecting");

        let ws;
        try {
          ws = new WebSocket(socketUrl());
        } catch (_) {
          state.connecting = false;
          setStatus("OFFLINE", "closed");
          return;
        }
        state.ws = ws;

        ws.onopen = () => {
          state.connecting = false;
          send({ t: "auth", token: state.token, id: 1 }, true);
        };

        ws.onmessage = (event) => {
          let msg;
          try { msg = JSON.parse(event.data); } catch (_) { return; }

          if (msg.id === 1) {
            if (msg.ok) {
              state.authed = true;
              state.authNoticeShown = false;
              setStatus("CONNECTED", "open");
              toast("Connected");
            } else {
              state.authed = false;
              setStatus("LOCKED", "locked");
              if (!state.authNoticeShown) {
                state.authNoticeShown = true;
                toast("Access token rejected — tap the status pill");
              }
            }
            return;
          }

          if (msg.error) {
            if (msg.id !== undefined) {
              const pending = state.pending.get(msg.id);
              if (pending) {
                state.pending.delete(msg.id);
                pending(msg);
                return;
              }
            }
            toast(msg.error.message || "Command failed");
            if (msg.error.code === "bad_token" || msg.error.code === "unauthenticated") {
              state.authed = false;
              setStatus("LOCKED", "locked");
              warnConnection("Session locked — tap the status pill");
            }
            return;
          }

          if (msg.id !== undefined) {
            const pending = state.pending.get(msg.id);
            if (pending) {
              state.pending.delete(msg.id);
              pending(msg);
            }
          }
        };

        ws.onclose = () => {
          state.authed = false;
          state.connecting = false;
          setStatus("OFFLINE", "closed");
          clearTimeout(state.retry);
          state.retry = setTimeout(connect, 2500);
        };

        ws.onerror = () => {
          setStatus("OFFLINE", "closed");
        };
      }

      function send(payload, force) {
        if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
          if (!state.connecting) {
            setStatus(state.token ? "OFFLINE" : "SET TOKEN", state.token ? "closed" : "locked");
          }
          warnConnection(state.connecting ? "Still connecting to the PC" : (state.token ? "PC connection is offline" : "Tap SET TOKEN to connect"));
          return false;
        }
        if (!force && !state.authed) {
          setStatus(state.token ? "LOCKED" : "SET TOKEN", "locked");
          warnConnection(state.token ? "Tap the status pill to unlock" : "Tap SET TOKEN to connect");
          return false;
        }
        state.ws.send(JSON.stringify(payload));
        return true;
      }

      function request(payload) {
        return new Promise((resolve, reject) => {
          const id = state.nextId++;
          const timer = setTimeout(() => {
            state.pending.delete(id);
            reject(new Error("timeout"));
          }, 6000);
          payload.id = id;
          state.pending.set(id, (message) => {
            clearTimeout(timer);
            resolve(message);
          });
          if (!send(payload)) {
            clearTimeout(timer);
            state.pending.delete(id);
            reject(new Error("not connected"));
          }
        });
      }

      function click(button) {
        send({ t: "click", button });
      }

      function mouse(action, button = "left") {
        return send({ t: "mouse", action, button });
      }

      function scroll(delta) {
        send({ t: "scroll", delta });
      }

      const HOLD_MS = 280;
      const TAP_SLOP = 8;
      const HOLD_SLOP = 14;
      const RIGHT_ZONE = 0.70;

      let pointer = null;
      let holdTimer = null;

      function clearHoldTimer() {
        if (holdTimer !== null) {
          clearTimeout(holdTimer);
          holdTimer = null;
        }
      }

      function startHold(gesture) {
        clearHoldTimer();
        holdTimer = setTimeout(() => {
          holdTimer = null;
          if (pointer !== gesture || gesture.moved > HOLD_SLOP) return;
          if (!mouse("down", "left")) return;
          gesture.holding = true;
          trackpad.classList.add("holding");
          try { navigator.vibrate?.(12); } catch (_) {}
        }, HOLD_MS);
      }

      function releasePointer(cancelled) {
        const gesture = pointer;
        clearHoldTimer();
        pointer = null;
        if (!gesture) return;

        const wasHolding = gesture.holding;
        if (wasHolding) {
          mouse("up", "left");
          gesture.holding = false;
          trackpad.classList.remove("holding");
        }
        if (!cancelled && !wasHolding && gesture.moved < TAP_SLOP) {
          click(gesture.zone === "right" ? "right" : "left");
        }
      }

      trackpad.addEventListener("pointerdown", (event) => {
        if (event.button === 2) {
          event.preventDefault();
          click("right");
          return;
        }
        if (event.button !== 0) return;
        if (pointer) releasePointer(true);

        const rect = trackpad.getBoundingClientRect();
        const zone = (event.clientX - rect.left) >= rect.width * RIGHT_ZONE ? "right" : "left";
        const gesture = {
          x: event.clientX,
          y: event.clientY,
          zone,
          moved: 0,
          holding: false,
        };
        pointer = gesture;
        if (zone === "left") startHold(gesture);
        try { trackpad.setPointerCapture(event.pointerId); } catch (_) {}
      });

      trackpad.addEventListener("pointermove", (event) => {
        if (!pointer) return;
        const dx = Math.round(event.clientX - pointer.x);
        const dy = Math.round(event.clientY - pointer.y);
        if (dx === 0 && dy === 0) return;
        pointer.x = event.clientX;
        pointer.y = event.clientY;
        pointer.moved += Math.abs(dx) + Math.abs(dy);
        if (!pointer.holding && pointer.moved > HOLD_SLOP) clearHoldTimer();
        send({ t: "move", dx, dy });
      });

      trackpad.addEventListener("pointerup", () => releasePointer(false));
      trackpad.addEventListener("pointercancel", () => releasePointer(true));
      trackpad.addEventListener("lostpointercapture", () => releasePointer(true));
      window.addEventListener("blur", () => releasePointer(true));
      window.addEventListener("pagehide", () => releasePointer(true));
      document.addEventListener("visibilitychange", () => {
        if (document.hidden) releasePointer(true);
      });
      trackpad.addEventListener("contextmenu", (event) => {
        // A long press must not open the phone's browser menu or create a
        // second right-click after the zone gesture handles it.
        event.preventDefault();
      });
      trackpad.addEventListener("wheel", (event) => {
        event.preventDefault();
        scroll(event.deltaY < 0 ? 1 : -1);
      }, { passive: false });

      document.querySelectorAll("[data-click]").forEach((button) => {
        button.addEventListener("click", () => click(button.dataset.click));
      });

      document.querySelectorAll("[data-scroll]").forEach((button) => {
        button.addEventListener("click", () => {
          scroll(button.dataset.scroll === "up" ? 1 : -1);
        });
      });

      document.querySelectorAll("[data-key]").forEach((button) => {
        button.addEventListener("click", () => {
          send({ t: "key", code: button.dataset.key });
        });
      });

      document.querySelectorAll("[data-media]").forEach((button) => {
        button.addEventListener("click", () => send({ t: "media", action: button.dataset.media }));
      });

      $("marketBtn").addEventListener("click", async () => {
        try {
          const reply = await request({ t: "marketplace" });
          if (reply.ok) {
            toast("Opening Rebind Marketplace");
          } else {
            toast(reply.error?.message || "Could not open Marketplace");
          }
        } catch (error) {
          toast(error.message === "timeout" ? "No response from PC" : "Not connected");
        }
      });

      $("sendClip").addEventListener("click", () => {
        const text = clipText.value;
        if (!text) { toast("Nothing to send"); return; }
        send({ t: "clipboard.set", text });
        toast("Sent to PC clipboard");
      });

      $("pullClip").addEventListener("click", async () => {
        try {
          const reply = await request({ t: "clipboard.get" });
          clipText.value = reply.text || "";
          toast("Pulled from PC clipboard");
        } catch (_) {
          toast("Not connected");
        }
      });

      $("typeClip").addEventListener("click", () => {
        const text = clipText.value;
        if (!text) { toast("Nothing to type"); return; }
        send({ t: "type", text });
        toast("Typing on PC");
      });

      function openSheet() {
        sheet.classList.add("open");
        sheet.setAttribute("aria-hidden", "false");
        hostInput.value = state.host;
        portInput.value = String(state.port);
        tokenInput.value = state.token;
      }

      function closeSheet() {
        sheet.classList.remove("open");
        sheet.setAttribute("aria-hidden", "true");
      }

      statusEl.addEventListener("click", openSheet);
      statusEl.addEventListener("keydown", (event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault();
          openSheet();
        }
      });
      $("settingsBtn").addEventListener("click", openSheet);
      $("closeSheet").addEventListener("click", closeSheet);
      sheet.addEventListener("click", (event) => {
        if (event.target === sheet) closeSheet();
      });

      $("saveSettings").addEventListener("click", () => {
        const host = hostInput.value.trim();
        const port = Number(portInput.value);
        const nextToken = tokenInput.value.trim();
        if (!host || !Number.isFinite(port) || port < 1024 || port > 65535) {
          toast("Enter a valid address and port");
          return;
        }
        if (!nextToken) {
          toast("Paste the access token from Rebind");
          tokenInput.focus();
          return;
        }
        state.host = host;
        state.port = port;
        state.token = nextToken;
        state.authNoticeShown = false;
        try {
          localStorage.setItem(LS.host, state.host);
          localStorage.setItem(LS.port, String(state.port));
          localStorage.setItem(LS.token, state.token);
        } catch (_) {}
        closeSheet();
        connect();
      });

      $("reconnectBtn").addEventListener("click", () => {
        closeSheet();
        connect();
      });

      connect();
    })();
  </script>
</body>
</html>

1 file without text previews