<!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;
}
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: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: flex;
gap: 6px;
opacity: 0.4;
transition: opacity 0.3s;
}
#toolbar:hover {
opacity: 1;
}
#toolbar button {
background: rgba(0,0,0,0.55);
color: #ccc;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
user-select: none;
}
#toolbar button:hover {
background: rgba(255,255,255,0.15);
color: #fff;
}
#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;
}
</style>
</head>
<body>
<div id="status">
<span id="dot"></span>
<span id="status-text">Connecting...</span>
</div>
<div id="container">
<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="info">vnrit</div>
<div id="toolbar">
<button id="fullscreen-btn">Fullscreen</button>
</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');
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;
const vidW = displayWidth, vidH = displayHeight;
if (vidW <= 0 || vidH <= 0) {
cursorOverlay.style.left = (elW / 2) + 'px';
cursorOverlay.style.top = (elH / 2) + 'px';
cursorOverlay.style.display = 'block';
return;
}
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;
}
const cssX = renderX + (localCursorX / vidW) * renderW;
const cssY = renderY + (localCursorY / vidH) * 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 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() {
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 = () => {
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 = () => {
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 elW = rect.width;
const elH = rect.height;
const vidW = displayWidth;
const vidH = displayHeight;
if (vidW <= 0 || vidH <= 0 || elW <= 0 || elH <= 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;
}
const x = (clientX - rect.left - renderX) / renderW;
const y = (clientY - rect.top - renderY) / renderH;
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
return {
x: Math.round(x * vidW),
y: Math.round(y * vidH)
};
}
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;
}
document.addEventListener('mousemove', (e) => {
if (isSyntheticFromTouch()) 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() || e.button !== 0) return;
refreshDisplaySize();
const pos = videoPos(e.clientX, e.clientY);
if (pos) {
sendInput(`ma,${pos.x},${pos.y}`);
sendInput('md,1');
}
});
document.addEventListener('mouseup', (e) => {
if (isSyntheticFromTouch() || e.button !== 0) return;
sendInput('mu,1');
});
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();
});
const fsBtn = document.getElementById('fullscreen-btn');
const container = document.getElementById('container');
function updateFsButton() {
fsBtn.textContent = document.fullscreenElement ? 'Exit' : 'Fullscreen';
}
fsBtn.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen();
}
});
document.addEventListener('fullscreenchange', updateFsButton);
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>