<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>vnrit</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #111;
color: #ccc;
font-family: -apple-system, sans-serif;
height: 100vh;
overflow: hidden;
}
#status {
position: fixed;
top: 4px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
gap: 4px;
user-select: none;
pointer-events: none;
}
#status-text {
font-size: 11px;
color: rgba(255,255,255,0.3);
transition: opacity 0.5s;
}
#dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #f44;
display: inline-block;
transition: background 0.3s;
}
#dot.connected { background: #4f4; }
#container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
cursor: none;
}
#viewport {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
transition: transform 0.22s cubic-bezier(0.33, 1, 0.68, 1);
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
background: #000;
cursor: crosshair;
image-rendering: auto;
touch-action: none;
}
#info {
position: fixed;
bottom: 8px;
right: 48px;
font-size: 12px;
color: rgba(255,255,255,0.25);
pointer-events: none;
z-index: 5;
}
#pointer-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255,255,255,0.4);
font-size: 14px;
pointer-events: none;
display: none;
text-align: center;
transition: opacity 1s;
background: rgba(0,0,0,0.5);
padding: 12px 20px;
border-radius: 8px;
}
#pointer-hint.fade {
opacity: 0;
}
#toolbar {
position: fixed;
bottom: 10px;
right: 10px;
left: auto;
transform: none;
z-index: 50;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0.35;
transition: opacity 0.3s;
pointer-events: auto;
}
#toolbar:hover,
#toolbar:active {
opacity: 1;
}
#toolbar button {
background: rgba(0,0,0,0.5);
color: #ccc;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 5px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
user-select: none;
touch-action: none;
}
#toolbar button:hover {
background: rgba(255,255,255,0.12);
color: #fff;
}
#kbd-btn {
font-size: 14px;
line-height: 1;
padding: 2px 6px;
}
#audio-hint {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
color: rgba(255,255,255,0.5);
font-size: 12px;
pointer-events: none;
display: none;
background: rgba(0,0,0,0.5);
padding: 6px 14px;
border-radius: 4px;
z-index: 10;
}
#cursor-overlay {
position: absolute;
width: 22px;
height: 22px;
pointer-events: none;
z-index: 999;
display: none;
transform: translate(-50%, -50%);
box-shadow: 0 0 6px rgba(0,0,0,0.5);
background: radial-gradient(
circle at 50% 50%,
#fff 2px,
#fff 3px,
rgba(0,0,0,0.85) 3.5px,
rgba(0,0,0,0.85) 4.5px,
transparent 5px
);
border-radius: 50%;
transition: left 0.015s ease-out, top 0.015s ease-out;
will-change: left, top;
}
#custom-keyboard {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 38vh;
background: rgba(18,18,22,0.92);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
z-index: 200;
display: flex;
flex-direction: column;
padding: 4px 3px 2px;
transform: translateY(100%);
transition: transform 0.22s cubic-bezier(0.33, 1, 0.68, 1);
user-select: none;
touch-action: none;
-webkit-tap-highlight-color: transparent;
border-top: 1px solid rgba(255,255,255,0.06);
}
#custom-keyboard.visible {
transform: translateY(0);
}
.kbd-layer {
display: none;
flex: 1;
flex-direction: column;
justify-content: space-evenly;
padding: 1px 0;
}
.kbd-layer.active {
display: flex;
}
.kbd-row {
display: flex;
gap: 2px;
justify-content: center;
align-items: stretch;
flex: 1;
}
.kbd-key {
flex: 1;
min-width: 0;
background: rgba(255,255,255,0.08);
color: #ddd;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 5px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0 2px;
transition: background 0.05s, transform 0.05s, border-color 0.15s, box-shadow 0.15s;
touch-action: none;
-webkit-tap-highlight-color: transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
will-change: transform;
position: relative;
}
.kbd-key:active {
background: rgba(255,255,255,0.18);
transform: scale(0.94);
}
.kbd-key.wide {
flex: 1.6;
}
.kbd-key.xwide {
flex: 2.2;
}
.kbd-key.xxwide {
flex: 3.5;
}
.kbd-key.mod-active {
border-color: #4a9eff;
box-shadow: inset 0 0 6px rgba(74,158,255,0.25);
}
.kbd-key.mod-latched {
background: rgba(74,158,255,0.12);
border-color: rgba(74,158,255,0.5);
}
.kbd-key.mod-locked {
background: rgba(74,158,255,0.22);
border-color: #4a9eff;
box-shadow: inset 0 0 8px rgba(74,158,255,0.35);
}
.kbd-key.special {
font-size: 9px;
}
.kbd-key .sub {
font-size: 8px;
position: absolute;
top: 2px;
right: 3px;
color: rgba(255,255,255,0.35);
}
.kbd-bottom-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px 1px;
border-top: 1px solid rgba(255,255,255,0.05);
flex-shrink: 0;
}
.kbd-bottom-bar button {
background: rgba(255,255,255,0.06);
color: #999;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 5px;
padding: 3px 6px;
font-size: 9px;
cursor: pointer;
touch-action: none;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.kbd-bottom-bar button:active {
background: rgba(255,255,255,0.14);
}
.kbd-bottom-bar .layer-active {
background: rgba(74,158,255,0.25);
color: #fff;
border-color: rgba(74,158,255,0.4);
}
.kbd-bottom-bar .kbd-spacer {
flex: 1;
}
.kbd-dismiss {
font-size: 14px;
padding: 5px 14px;
}
.kbd-key.dim {
color: rgba(255,255,255,0.15);
}
body.kbd-open {
overflow: hidden;
}
</style>
</head>
<body>
<div id="status">
<span id="dot"></span>
<span id="status-text">Connecting...</span>
</div>
<div id="container">
<div id="viewport">
<video id="remote-video" autoplay playsinline muted></video>
<div id="cursor-overlay"></div>
<div id="pointer-hint">Click to interact<br><small>(right-click for menu)</small></div>
<div id="audio-hint">Tap to enable audio</div>
</div>
<div id="toolbar">
<button id="fullscreen-btn">Fullscreen</button>
<button id="kbd-btn">⌨️</button>
</div>
<div id="custom-keyboard" style="position:fixed;">
<div class="kbd-layer active" data-layer="main">
<div class="kbd-row">
<button class="kbd-key special" data-code="Escape">Esc</button>
<button class="kbd-key" data-code="Backquote">`</button>
<button class="kbd-key" data-code="Digit1">1</button>
<button class="kbd-key" data-code="Digit2">2</button>
<button class="kbd-key" data-code="Digit3">3</button>
<button class="kbd-key" data-code="Digit4">4</button>
<button class="kbd-key" data-code="Digit5">5</button>
<button class="kbd-key" data-code="Digit6">6</button>
<button class="kbd-key" data-code="Digit7">7</button>
<button class="kbd-key" data-code="Digit8">8</button>
<button class="kbd-key" data-code="Digit9">9</button>
<button class="kbd-key" data-code="Digit0">0</button>
<button class="kbd-key special" data-code="Minus">-</button>
<button class="kbd-key special" data-code="Equal">=</button>
<button class="kbd-key wide special" data-code="Backspace">⌫</button>
</div>
<div class="kbd-row">
<button class="kbd-key wide special" data-code="Tab">Tab</button>
<button class="kbd-key" data-code="KeyQ">Q</button>
<button class="kbd-key" data-code="KeyW">W</button>
<button class="kbd-key" data-code="KeyE">E</button>
<button class="kbd-key" data-code="KeyR">R</button>
<button class="kbd-key" data-code="KeyT">T</button>
<button class="kbd-key" data-code="KeyY">Y</button>
<button class="kbd-key" data-code="KeyU">U</button>
<button class="kbd-key" data-code="KeyI">I</button>
<button class="kbd-key" data-code="KeyO">O</button>
<button class="kbd-key" data-code="KeyP">P</button>
<button class="kbd-key special" data-code="BracketLeft">[</button>
<button class="kbd-key special" data-code="BracketRight">]</button>
<button class="kbd-key wide special" data-code="Backslash">\</button>
</div>
<div class="kbd-row">
<button class="kbd-key xwide special" data-code="CapsLock">Caps</button>
<button class="kbd-key" data-code="KeyA">A</button>
<button class="kbd-key" data-code="KeyS">S</button>
<button class="kbd-key" data-code="KeyD">D</button>
<button class="kbd-key" data-code="KeyF">F</button>
<button class="kbd-key" data-code="KeyG">G</button>
<button class="kbd-key" data-code="KeyH">H</button>
<button class="kbd-key" data-code="KeyJ">J</button>
<button class="kbd-key" data-code="KeyK">K</button>
<button class="kbd-key" data-code="KeyL">L</button>
<button class="kbd-key special" data-code="Semicolon">;</button>
<button class="kbd-key special" data-code="Quote">'</button>
<button class="kbd-key xwide special" data-code="Enter">Enter</button>
</div>
<div class="kbd-row">
<button class="kbd-key xwide mod-key special" data-code="ShiftLeft">Shift</button>
<button class="kbd-key" data-code="KeyZ">Z</button>
<button class="kbd-key" data-code="KeyX">X</button>
<button class="kbd-key" data-code="KeyC">C</button>
<button class="kbd-key" data-code="KeyV">V</button>
<button class="kbd-key" data-code="KeyB">B</button>
<button class="kbd-key" data-code="KeyN">N</button>
<button class="kbd-key" data-code="KeyM">M</button>
<button class="kbd-key special" data-code="Comma">,</button>
<button class="kbd-key special" data-code="Period">.</button>
<button class="kbd-key special" data-code="Slash">/</button>
<button class="kbd-key xwide mod-key special" data-code="ShiftRight">Shift</button>
</div>
<div class="kbd-row">
<button class="kbd-key wide mod-key special" data-code="ControlLeft">Ctrl</button>
<button class="kbd-key mod-key special" data-code="AltLeft">Alt</button>
<button class="kbd-key xxwide" data-code="Space">Space</button>
<button class="kbd-key mod-key special" data-code="AltRight">Alt</button>
<button class="kbd-key wide mod-key special" data-code="ControlRight">Ctrl</button>
<button class="kbd-key special" data-code="ArrowLeft">◀</button>
<button class="kbd-key special" data-code="ArrowUp">▲</button>
<button class="kbd-key special" data-code="ArrowDown">▼</button>
<button class="kbd-key special" data-code="ArrowRight">▶</button>
</div>
</div>
<div class="kbd-layer" data-layer="func">
<div class="kbd-row">
<button class="kbd-key special" data-code="F1">F1</button>
<button class="kbd-key special" data-code="F2">F2</button>
<button class="kbd-key special" data-code="F3">F3</button>
<button class="kbd-key special" data-code="F4">F4</button>
<button class="kbd-key special" data-code="F5">F5</button>
<button class="kbd-key special" data-code="F6">F6</button>
</div>
<div class="kbd-row">
<button class="kbd-key special" data-code="F7">F7</button>
<button class="kbd-key special" data-code="F8">F8</button>
<button class="kbd-key special" data-code="F9">F9</button>
<button class="kbd-key special" data-code="F10">F10</button>
<button class="kbd-key special" data-code="F11">F11</button>
<button class="kbd-key special" data-code="F12">F12</button>
</div>
<div class="kbd-row">
<button class="kbd-key special" data-code="PrintScreen">PrtSc</button>
<button class="kbd-key special" data-code="ScrollLock">ScrLk</button>
<button class="kbd-key special" data-code="Pause">Pause</button>
<button class="kbd-key special" data-code="Break">Break</button>
<button class="kbd-key special" data-code="SysRq">SysRq</button>
</div>
<div class="kbd-row">
<button class="kbd-key special" data-code="Insert">Ins</button>
<button class="kbd-key special" data-code="Delete">Del</button>
<button class="kbd-key special" data-code="Home">Home</button>
<button class="kbd-key special" data-code="End">End</button>
<button class="kbd-key special" data-code="PageUp">PgUp</button>
<button class="kbd-key special" data-code="PageDown">PgDn</button>
</div>
</div>
<div class="kbd-layer" data-layer="num">
<div class="kbd-row">
<button class="kbd-key special" data-code="NumLock">NumLk</button>
<button class="kbd-key special" data-code="NumpadDivide">/</button>
<button class="kbd-key special" data-code="NumpadMultiply">*</button>
<button class="kbd-key special" data-code="NumpadSubtract">-</button>
</div>
<div class="kbd-row">
<button class="kbd-key" data-code="Numpad7">7</button>
<button class="kbd-key" data-code="Numpad8">8</button>
<button class="kbd-key" data-code="Numpad9">9</button>
<button class="kbd-key special" data-code="NumpadAdd">+</button>
</div>
<div class="kbd-row">
<button class="kbd-key" data-code="Numpad4">4</button>
<button class="kbd-key" data-code="Numpad5">5</button>
<button class="kbd-key" data-code="Numpad6">6</button>
<button class="kbd-key wide special" data-code="NumpadDecimal">.</button>
</div>
<div class="kbd-row">
<button class="kbd-key" data-code="Numpad1">1</button>
<button class="kbd-key" data-code="Numpad2">2</button>
<button class="kbd-key" data-code="Numpad3">3</button>
<button class="kbd-key wide special" data-code="NumpadEnter">Ent</button>
</div>
<div class="kbd-row">
<button class="kbd-key xxwide" data-code="Numpad0">0</button>
<button class="kbd-key" data-code="NumpadDecimal">.</button>
</div>
</div>
<div class="kbd-bottom-bar">
<button id="kbd-layer-fn" class="kbd-layer-btn" data-target="func">Fn</button>
<button id="kbd-layer-num" class="kbd-layer-btn" data-target="num">123</button>
<span class="kbd-spacer"></span>
<button class="kbd-dismiss">▼</button>
</div>
</div>
</div>
<div id="info">vnrit</div>
<script>
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const urlParams = new URLSearchParams(location.search);
const authToken = urlParams.get('token');
let wsUrl = `${wsProto}//${location.host}/ws`;
if (authToken) {
wsUrl += `?token=${encodeURIComponent(authToken)}`;
}
const STUN_SERVER = '{{STUN_SERVER}}';
const video = document.getElementById('remote-video');
const dot = document.getElementById('dot');
const statusText = document.getElementById('status-text');
const pointerHint = document.getElementById('pointer-hint');
const fsBtn = document.getElementById('fullscreen-btn');
const container = document.getElementById('container');
const viewport = document.getElementById('viewport');
let pc = null;
let ws = null;
let displayWidth = 0;
let displayHeight = 0;
let reconnectDelay = 1000;
let localCursorX = 0;
let localCursorY = 0;
const cursorOverlay = document.getElementById('cursor-overlay');
function updateCursorOverlay() {
const rect = video.getBoundingClientRect();
const elW = rect.width, elH = rect.height;
if (elW <= 0 || elH <= 0) return;
if (displayWidth <= 0 || displayHeight <= 0) {
cursorOverlay.style.left = (elW / 2) + 'px';
cursorOverlay.style.top = (elH / 2) + 'px';
cursorOverlay.style.display = 'block';
return;
}
const r = getVideoRenderRect();
if (!r) return;
const cssX = r.renderX + (localCursorX / displayWidth) * r.renderW;
const cssY = r.renderY + (localCursorY / displayHeight) * r.renderH;
cursorOverlay.style.left = cssX + 'px';
cursorOverlay.style.top = cssY + 'px';
cursorOverlay.style.display = 'block';
}
function capLocalCursor() {
localCursorX = Math.max(0, Math.min(displayWidth - 1, localCursorX));
localCursorY = Math.max(0, Math.min(displayHeight - 1, localCursorY));
}
function getVideoRenderRect() {
const rect = video.getBoundingClientRect();
const elW = rect.width, elH = rect.height;
if (elW <= 0 || elH <= 0) return null;
const vidW = displayWidth, vidH = displayHeight;
if (vidW <= 0 || vidH <= 0) return null;
const elAspect = elW / elH;
const vidAspect = vidW / vidH;
let renderW, renderH, renderX, renderY;
if (vidAspect > elAspect) {
renderW = elW;
renderH = elW / vidAspect;
renderX = 0;
renderY = (elH - renderH) / 2;
} else {
renderH = elH;
renderW = elH * vidAspect;
renderX = (elW - renderW) / 2;
renderY = 0;
}
return { renderW, renderH, renderX, renderY };
}
function setStatus(text, connected) {
statusText.textContent = text;
dot.className = connected ? 'connected' : '';
}
let relAccDx = 0, relAccDy = 0;
let throttleTimer = null;
let lastAbsMoveTs = 0;
const THROTTLE_MS = 20;
function flushRelInput() {
const dx = relAccDx;
const dy = relAccDy;
relAccDx = 0;
relAccDy = 0;
if ((dx !== 0 || dy !== 0) && ws && ws.readyState === WebSocket.OPEN) {
ws.send(`mr,${dx},${dy}`);
}
}
function startInputThrottle() {
stopInputThrottle();
throttleTimer = setInterval(flushRelInput, THROTTLE_MS);
}
function stopInputThrottle() {
if (throttleTimer) {
clearInterval(throttleTimer);
throttleTimer = null;
}
flushRelInput();
}
function sendInput(csv) {
if (csv.startsWith('ma,')) {
const parts = csv.split(',');
localCursorX = parseInt(parts[1], 10);
localCursorY = parseInt(parts[2], 10);
capLocalCursor();
updateCursorOverlay();
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(csv);
}
}
function connect() {
releaseAllModifiers();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
setStatus('Connecting...', false);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
setStatus('Connected', true);
reconnectDelay = 1000;
ws.send(JSON.stringify({ type: 'ready' }));
};
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'offer':
try {
await handleOffer(msg.sdp);
} catch (e) {
console.error('[offer] handleOffer failed:', e);
ws.close();
}
break;
case 'ice':
if (pc) {
try {
await pc.addIceCandidate(new RTCIceCandidate({
candidate: msg.candidate,
sdpMLineIndex: msg.sdp_mline_index,
}));
} catch (e) {}
}
break;
case 'cursor':
if (typeof msg.x === 'number' && typeof msg.y === 'number') {
localCursorX = msg.x;
localCursorY = msg.y;
capLocalCursor();
updateCursorOverlay();
}
break;
case 'error':
setStatus('Error: ' + msg.error, false);
break;
}
};
ws.onclose = () => {
releaseAllModifiers();
setStatus('Disconnected, retrying...', false);
cursorOverlay.style.display = 'none';
if (pc) { pc.close(); pc = null; }
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
ws.onerror = () => {
releaseAllModifiers();
ws.close();
};
}
async function handleOffer(sdp) {
if (pc) { pc.close(); pc = null; }
setStatus('Negotiating...', false);
const pcConfig = {};
if (STUN_SERVER) {
pcConfig.iceServers = [{ urls: STUN_SERVER.replace('stun://', 'stun:') }];
} else {
pcConfig.iceServers = [];
pcConfig.iceTransportPolicy = 'host';
}
pc = new RTCPeerConnection(pcConfig);
pc.ontrack = (event) => {
if (event.track.kind === 'video') {
video.srcObject = event.streams[0];
video.onloadedmetadata = () => {
displayWidth = video.videoWidth;
displayHeight = video.videoHeight;
console.log(`[video] ${displayWidth}x${displayHeight}`);
updateCursorOverlay();
};
setStatus('●', true);
document.getElementById('status-text').style.opacity = '0';
pointerHint.style.display = 'block';
}
};
pc.onicecandidate = (event) => {
if (event.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ice',
candidate: event.candidate.candidate,
sdp_mline_index: event.candidate.sdpMLineIndex,
}));
}
};
const onConnectionFail = () => {
if (!pc) return;
const st = pc.connectionState;
const ice = pc.iceConnectionState;
if (st === 'failed' || st === 'disconnected' ||
ice === 'failed' || ice === 'disconnected') {
console.warn(`[pc] connection failed (connectionState=${st}, iceConnectionState=${ice})`);
setStatus('Connection lost', false);
ws.close();
}
};
pc.onconnectionstatechange = onConnectionFail;
pc.oniceconnectionstatechange = onConnectionFail;
try {
const remoteDesc = { type: 'offer', sdp: sdp };
await pc.setRemoteDescription(new RTCSessionDescription(remoteDesc));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
if (ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket closed before answer could be sent');
}
ws.send(JSON.stringify({
type: 'answer',
sdp: answer.sdp,
}));
setTimeout(() => {
if (pc && pc.connectionState !== 'connected' && pc.connectionState !== 'closed') {
console.warn('[pc] negotiation timeout (15s), restarting');
setStatus('Negotiation timeout, retrying...', false);
ws.close();
}
}, 15000);
} catch (e) {
console.error('[offer] negotiation error:', e);
setStatus('Error: ' + (e.message || e), false);
ws.close();
}
}
function videoPos(clientX, clientY) {
const rect = video.getBoundingClientRect();
const r = getVideoRenderRect();
if (!r) return null;
const x = (clientX - rect.left - r.renderX) / r.renderW;
const y = (clientY - rect.top - r.renderY) / r.renderH;
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
return {
x: Math.round(x * displayWidth),
y: Math.round(y * displayHeight)
};
}
function refreshDisplaySize() {
const w = video.videoWidth;
const h = video.videoHeight;
if (w > 0 && h > 0) {
displayWidth = w;
displayHeight = h;
updateCursorOverlay();
}
}
function cancelPendingInputs() {
relAccDx = 0;
relAccDy = 0;
}
window.addEventListener('resize', refreshDisplaySize);
window.addEventListener('orientationchange', () => {
setTimeout(refreshDisplaySize, 300);
});
let lastTouchEndTime = 0;
function isSyntheticFromTouch() {
return touchActive || Date.now() - lastTouchEndTime < 200;
}
function isOnUI(el) {
return el.closest('#toolbar') || el.closest('#custom-keyboard');
}
document.addEventListener('mousemove', (e) => {
if (isSyntheticFromTouch() || isOnUI(e.target)) return;
refreshDisplaySize();
const pos = videoPos(e.clientX, e.clientY);
if (pos) {
localCursorX = pos.x;
localCursorY = pos.y;
capLocalCursor();
updateCursorOverlay();
const now = Date.now();
if (now - lastAbsMoveTs >= THROTTLE_MS) {
sendInput(`ma,${pos.x},${pos.y}`);
lastAbsMoveTs = now;
}
}
});
document.addEventListener('mousedown', (e) => {
if (isSyntheticFromTouch() || isOnUI(e.target)) return;
refreshDisplaySize();
const pos = videoPos(e.clientX, e.clientY);
if (pos) {
sendInput(`ma,${pos.x},${pos.y}`);
let btn = 0;
if (e.button === 0) btn = 1;
else if (e.button === 1) btn = 2;
else if (e.button === 2) btn = 3;
else return;
sendInput(`md,${btn}`);
}
});
document.addEventListener('mouseup', (e) => {
if (isSyntheticFromTouch() || isOnUI(e.target)) return;
let btn = 0;
if (e.button === 0) btn = 1;
else if (e.button === 1) btn = 2;
else if (e.button === 2) btn = 3;
else return;
sendInput(`mu,${btn}`);
});
document.addEventListener('wheel', (e) => {
e.preventDefault();
sendInput(`ms,${e.deltaY}`);
}, { passive: false });
let touchActive = false;
let dragMode = false;
let dragStarted = false;
let lastWasTap = false;
let longPressTimer = null;
let lastTapTime = 0;
let startTouchTime = 0;
let touchStartX = 0;
let touchStartY = 0;
let isLongPress = false;
let longPressMoved = false;
let cursorMoved = false;
let lastScrollY = 0;
let lastTouchX = 0;
let lastTouchY = 0;
const MOVE_THRESHOLD = 15;
const SCROLL_THRESHOLD = 20;
const TAP_TIME_MAX = 300;
const LONG_PRESS_MS = 700;
const DBL_TAP_MAX = 400;
video.addEventListener('touchstart', (e) => {
e.preventDefault();
lastTouchEndTime = Date.now();
const t = e.touches[0];
refreshDisplaySize();
clearTimeout(longPressTimer);
const now = Date.now();
if (lastWasTap && (now - lastTapTime < DBL_TAP_MAX) && lastTapTime > 0) {
dragMode = true;
lastWasTap = false;
} else {
dragMode = false;
}
touchActive = true;
startTouchTime = now;
touchStartX = t.clientX;
touchStartY = t.clientY;
lastTouchX = t.clientX;
lastTouchY = t.clientY;
isLongPress = false;
dragStarted = false;
longPressMoved = false;
cursorMoved = false;
lastScrollY = t.clientY;
if (!dragMode) {
longPressTimer = setTimeout(() => {
if (touchActive && !dragMode) {
cancelPendingInputs();
isLongPress = true;
lastScrollY = touchStartY;
}
}, LONG_PRESS_MS);
}
}, { passive: false });
video.addEventListener('touchmove', (e) => {
e.preventDefault();
if (!touchActive) return;
const t = e.touches[0];
const dx = Math.abs(t.clientX - touchStartX);
const dy = Math.abs(t.clientY - touchStartY);
const moved = dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD;
if (!moved) return;
if (dragMode) {
if (!dragStarted) {
clearTimeout(longPressTimer);
cancelPendingInputs();
dragStarted = true;
sendInput('md,1');
}
cursorMoved = true;
const deltaXDrag = Math.round(t.clientX - lastTouchX);
const deltaYDrag = Math.round(t.clientY - lastTouchY);
lastTouchX = t.clientX;
lastTouchY = t.clientY;
if (deltaXDrag !== 0 || deltaYDrag !== 0) {
relAccDx += deltaXDrag;
relAccDy += deltaYDrag;
localCursorX += deltaXDrag;
localCursorY += deltaYDrag;
capLocalCursor();
updateCursorOverlay();
}
} else if (isLongPress) {
longPressMoved = true;
const scrollDy = t.clientY - lastScrollY;
if (Math.abs(scrollDy) >= SCROLL_THRESHOLD) {
const steps = Math.floor(Math.abs(scrollDy) / SCROLL_THRESHOLD);
const dir = scrollDy > 0 ? 1 : -1;
for (let i = 0; i < Math.min(steps, 5); i++) {
sendInput(`ms,${dir}`);
}
lastScrollY = t.clientY;
}
} else {
cursorMoved = true;
const deltaX = Math.round(t.clientX - lastTouchX);
const deltaY = Math.round(t.clientY - lastTouchY);
lastTouchX = t.clientX;
lastTouchY = t.clientY;
if (deltaX !== 0 || deltaY !== 0) {
relAccDx += deltaX;
relAccDy += deltaY;
localCursorX += deltaX;
localCursorY += deltaY;
capLocalCursor();
updateCursorOverlay();
}
}
}, { passive: false });
video.addEventListener('touchend', (e) => {
e.preventDefault();
clearTimeout(longPressTimer);
cancelPendingInputs();
const touchDuration = Date.now() - startTouchTime;
if (touchActive) {
if (dragStarted) {
sendInput('mu,1');
dragMode = false;
lastWasTap = false;
} else if (isLongPress && !longPressMoved) {
sendInput('md,3');
sendInput('mu,3');
lastWasTap = false;
} else if (touchDuration < TAP_TIME_MAX && !cursorMoved) {
sendInput('md,1');
sendInput('mu,1');
lastWasTap = true;
} else {
lastWasTap = false;
}
}
touchActive = false;
dragMode = false;
dragStarted = false;
cursorMoved = false;
longPressMoved = false;
lastTapTime = Date.now();
lastTouchEndTime = Date.now();
isLongPress = false;
}, { passive: false });
document.addEventListener('keydown', (e) => {
if (e.code === 'F11') {
e.preventDefault();
fsBtn.click();
return;
}
const preventKeys = ['Tab', 'F5', 'F12', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
if (preventKeys.includes(e.code) || e.ctrlKey || e.altKey || e.metaKey) {
e.preventDefault();
}
sendInput(`kd,${e.code}`);
});
document.addEventListener('keyup', (e) => {
sendInput(`ku,${e.code}`);
});
setTimeout(() => {
pointerHint.style.display = 'none';
}, 4000);
function hideHint() {
pointerHint.style.display = 'none';
document.removeEventListener('touchstart', hideHint);
document.removeEventListener('mousedown', hideHint);
}
document.addEventListener('touchstart', hideHint);
document.addEventListener('mousedown', hideHint);
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
function updateFsButton() {
fsBtn.textContent = document.fullscreenElement ? 'Exit' : 'Fullscreen';
}
fsBtn.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen();
}
});
document.addEventListener('fullscreenchange', updateFsButton);
const keyboard = document.getElementById('custom-keyboard');
const kbdBtn = document.getElementById('kbd-btn');
const allLayers = keyboard.querySelectorAll('.kbd-layer');
const layerBtns = keyboard.querySelectorAll('.kbd-layer-btn');
const dismissBtn = keyboard.querySelector('.kbd-dismiss');
const allKeys = keyboard.querySelectorAll('.kbd-key');
let keyboardVisible = false;
let currentLayer = 'main';
const MOD_CODES = ['ControlLeft', 'ControlRight', 'ShiftLeft', 'ShiftRight', 'AltLeft', 'AltRight'];
const modState = {};
for (const code of MOD_CODES) {
modState[code] = { active: false, latched: false, timer: null };
}
function updateModVisual(code) {
const state = modState[code];
keyboard.querySelectorAll(`.kbd-key[data-code="${code}"]`).forEach(el => {
el.classList.remove('mod-latched', 'mod-locked', 'mod-active');
if (state.active && state.latched) {
el.classList.add('mod-latched', 'mod-active');
} else if (state.active && !state.latched) {
el.classList.add('mod-locked', 'mod-active');
}
});
}
function releaseLatchedMods() {
for (const code of Object.keys(modState)) {
const m = modState[code];
if (m.active && m.latched) {
sendInput(`ku,${code}`);
m.active = false;
m.latched = false;
clearTimeout(m.timer);
m.timer = null;
updateModVisual(code);
}
}
}
function releaseAllModifiers() {
for (const code of Object.keys(modState)) {
const m = modState[code];
if (m.active) {
sendInput(`ku,${code}`);
}
clearTimeout(m.timer);
m.active = false;
m.latched = false;
m.timer = null;
updateModVisual(code);
}
}
function handleModKey(code) {
const m = modState[code];
if (!m) return;
if (m.active) {
sendInput(`ku,${code}`);
m.active = false;
m.latched = false;
clearTimeout(m.timer);
m.timer = null;
updateModVisual(code);
return;
}
sendInput(`kd,${code}`);
m.active = true;
if (m.timer) {
clearTimeout(m.timer);
m.timer = null;
m.latched = false;
updateModVisual(code);
return;
}
m.timer = setTimeout(() => {
m.timer = null;
m.latched = true;
updateModVisual(code);
}, 300);
}
function handleNormalKey(code) {
sendInput(`kd,${code}`);
sendInput(`ku,${code}`);
for (const modCode of Object.keys(modState)) {
const m = modState[modCode];
if (m.active && m.latched) {
sendInput(`ku,${modCode}`);
m.active = false;
m.latched = false;
clearTimeout(m.timer);
m.timer = null;
updateModVisual(modCode);
}
}
}
function handleKeyUp(code) {
}
function switchLayer(layer) {
if (layer === 'main' && currentLayer === 'main') return; if (layer === currentLayer) {
layer = 'main';
}
currentLayer = layer;
allLayers.forEach(l => l.classList.toggle('active', l.dataset.layer === layer));
layerBtns.forEach(btn => {
const target = btn.dataset.target;
btn.classList.toggle('layer-active', target === layer);
});
}
function getCode(el) {
const key = el.closest('.kbd-key');
return key ? key.dataset.code : null;
}
keyboard.addEventListener('click', (e) => {
e.stopPropagation();
});
keyboard.addEventListener('pointerdown', (e) => {
e.preventDefault();
const key = e.target.closest('.kbd-key');
if (!key) return;
const code = key.dataset.code;
if (!code) return;
try { key.setPointerCapture(e.pointerId); } catch(_) {}
if (MOD_CODES.includes(code)) {
handleModKey(code);
} else {
handleNormalKey(code);
}
});
keyboard.addEventListener('pointerup', (e) => {
const key = e.target.closest('.kbd-key');
if (!key) return;
const code = key.dataset.code;
if (!code) return;
handleKeyUp(code);
});
keyboard.addEventListener('pointerleave', (e) => {
});
layerBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const target = btn.dataset.target;
if (target === 'func' || target === 'num') {
switchLayer(target);
}
});
});
dismissBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
hideKeyboard();
});
kbdBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (keyboardVisible) {
hideKeyboard();
} else {
showKeyboard();
}
});
function showKeyboard() {
keyboardVisible = true;
keyboard.classList.add('visible');
document.body.classList.add('kbd-open');
currentLayer = 'main';
allLayers.forEach(l => l.classList.toggle('active', l.dataset.layer === 'main'));
layerBtns.forEach(btn => btn.classList.remove('layer-active'));
requestAnimationFrame(() => adjustVideoForKeyboard(true));
}
function hideKeyboard() {
keyboardVisible = false;
keyboard.classList.remove('visible');
document.body.classList.remove('kbd-open');
switchLayer('main');
releaseAllModifiers();
adjustVideoForKeyboard(false);
}
function adjustVideoForKeyboard(show) {
const toolbar = document.getElementById('toolbar');
if (show) {
const kbdHeight = keyboard.offsetHeight;
viewport.style.transform = `translateY(-${kbdHeight}px)`;
toolbar.style.display = 'none';
} else {
viewport.style.transform = 'translateY(0)';
toolbar.style.display = 'flex';
}
}
window.addEventListener('resize', () => {
if (keyboardVisible) adjustVideoForKeyboard(true);
});
container.addEventListener('click', (e) => {
if (keyboardVisible && !e.target.closest('#toolbar') && !e.target.closest('#custom-keyboard')) {
hideKeyboard();
}
});
const audioHint = document.getElementById('audio-hint');
let audioEnabled = false;
function enableAudio() {
if (audioEnabled) return;
audioEnabled = true;
video.muted = false;
audioHint.style.display = 'none';
document.removeEventListener('click', enableAudio);
document.removeEventListener('touchstart', enableAudio);
}
video.addEventListener('playing', () => {
if (!audioEnabled) {
audioHint.style.display = 'block';
setTimeout(() => { audioHint.style.display = 'none'; }, 8000);
}
});
document.addEventListener('click', enableAudio);
document.addEventListener('touchstart', enableAudio);
connect();
startInputThrottle();
setTimeout(updateCursorOverlay, 100);
window.econsole = console;
</script>
</body>
</html>