mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
877 lines
35 KiB
HTML
877 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>x0gp · WebRTC Cockpit</title>
|
|
<!-- Modern typography -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg-color: #0b0f19;
|
|
--panel-bg: rgba(17, 24, 39, 0.7);
|
|
--border-color: rgba(255, 255, 255, 0.08);
|
|
--primary: #10b981; /* Emerald */
|
|
--primary-hover: #059669;
|
|
--accent: #3b82f6; /* Blue */
|
|
--text-main: #f3f4f6;
|
|
--text-muted: #9ca3af;
|
|
--danger: #ef4444;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Outfit', sans-serif;
|
|
background-color: var(--bg-color);
|
|
color: var(--text-main);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-x: hidden;
|
|
background-image:
|
|
radial-gradient(circle at 10% 20%, rgba(16, 185, 129, 0.05) 0%, transparent 40%),
|
|
radial-gradient(circle at 90% 80%, rgba(59, 130, 246, 0.05) 0%, transparent 40%);
|
|
}
|
|
|
|
header {
|
|
padding: 1.5rem 2rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--border-color);
|
|
backdrop-filter: blur(10px);
|
|
z-index: 10;
|
|
}
|
|
|
|
.logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
font-weight: 800;
|
|
font-size: 1.5rem;
|
|
letter-spacing: -0.05em;
|
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.status-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
padding: 0.375rem 0.75rem;
|
|
border-radius: 9999px;
|
|
background-color: rgba(255, 255, 255, 0.03);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background-color: var(--danger);
|
|
box-shadow: 0 0 8px var(--danger);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.status-dot.connected {
|
|
background-color: var(--primary);
|
|
box-shadow: 0 0 8px var(--primary);
|
|
}
|
|
|
|
.status-dot.connecting {
|
|
background-color: #f59e0b;
|
|
box-shadow: 0 0 8px #f59e0b;
|
|
animation: pulse 1s infinite alternate;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
from { opacity: 0.5; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
main {
|
|
flex: 1;
|
|
padding: 2rem;
|
|
max-width: 1400px;
|
|
width: 100%;
|
|
margin: 0 auto;
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 2rem;
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
main {
|
|
grid-template-columns: 1.6fr 1fr;
|
|
}
|
|
}
|
|
|
|
.glass-panel {
|
|
background: var(--panel-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
padding: 1.5rem;
|
|
backdrop-filter: blur(16px);
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
transition: border-color 0.3s ease;
|
|
}
|
|
|
|
.glass-panel:hover {
|
|
border-color: rgba(255, 255, 255, 0.12);
|
|
}
|
|
|
|
h2 {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
margin-bottom: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.auth-bar {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
input {
|
|
flex: 1;
|
|
background: rgba(0, 0, 0, 0.25);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 0.75rem 1rem;
|
|
color: var(--text-main);
|
|
font-family: inherit;
|
|
font-size: 0.95rem;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15);
|
|
}
|
|
|
|
button {
|
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 0.75rem 1.5rem;
|
|
color: white;
|
|
font-family: inherit;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
|
|
}
|
|
|
|
button:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3);
|
|
}
|
|
|
|
button:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
button.disconnect {
|
|
background: linear-gradient(135deg, var(--danger), #dc2626);
|
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
|
|
}
|
|
|
|
.video-container {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 4/3;
|
|
background: #020617;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
|
box-shadow: inset 0 0 40px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.video-feed {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
display: none;
|
|
}
|
|
|
|
.video-placeholder {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.video-placeholder svg {
|
|
width: 48px;
|
|
height: 48px;
|
|
stroke: var(--text-muted);
|
|
opacity: 0.6;
|
|
animation: bounce 2s infinite;
|
|
}
|
|
|
|
@keyframes bounce {
|
|
0%, 100% { transform: translateY(0); }
|
|
50% { transform: translateY(-6px); }
|
|
}
|
|
|
|
.telemetry-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.telemetry-card {
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.telemetry-label {
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.telemetry-value {
|
|
font-size: 1.5rem;
|
|
font-weight: 800;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.controller-viz {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1.5rem;
|
|
margin-top: 1.5rem;
|
|
padding-top: 1.5rem;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.steering-wheel-container {
|
|
position: relative;
|
|
width: 120px;
|
|
height: 120px;
|
|
}
|
|
|
|
.steering-wheel {
|
|
width: 100%;
|
|
height: 100%;
|
|
transition: transform 0.1s ease-out;
|
|
transform-origin: center center;
|
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5));
|
|
}
|
|
|
|
.keys-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 0.5rem;
|
|
width: 160px;
|
|
}
|
|
|
|
.key-btn {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
height: 45px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
color: var(--text-muted);
|
|
transition: all 0.1s ease;
|
|
user-select: none;
|
|
}
|
|
|
|
.key-btn.active {
|
|
background: var(--primary);
|
|
color: #0b0f19;
|
|
box-shadow: 0 0 12px var(--primary);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.key-btn.empty {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.console {
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
padding: 1rem;
|
|
height: 180px;
|
|
overflow-y: auto;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.8rem;
|
|
color: #10b981;
|
|
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
.console-entry {
|
|
margin-bottom: 0.35rem;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.console-time {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.console-in {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.console-out {
|
|
color: var(--primary);
|
|
}
|
|
|
|
footer {
|
|
padding: 1.5rem;
|
|
text-align: center;
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
border-top: 1px solid var(--border-color);
|
|
margin-top: auto;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="logo">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>
|
|
x0gp <span style="font-weight: 300;">Cockpit</span>
|
|
</div>
|
|
<div class="status-badge">
|
|
<div id="statusDot" class="status-dot"></div>
|
|
<span id="statusText">Disconnected</span>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<!-- Left: Video Feed Panel -->
|
|
<div class="glass-panel" style="display: flex; flex-direction: column;">
|
|
<h2>
|
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
|
Real-Time Video Feed
|
|
</h2>
|
|
<div class="video-container">
|
|
<img id="videoFeed" class="video-feed" alt="WebRTC Stream">
|
|
<div id="videoPlaceholder" class="video-placeholder">
|
|
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0012.016 15a4.486 4.486 0 00-3.198 1.318M21 12a9 9 0 11-18 0 9 9 0 0118 0zM9.75 9.75c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75z"/></svg>
|
|
<p>Connect the cockpit to receive real-time video</p>
|
|
</div>
|
|
</div>
|
|
<div class="controller-viz">
|
|
<div class="steering-wheel-container">
|
|
<!-- Steering wheel SVG -->
|
|
<svg id="steeringWheel" class="steering-wheel" viewBox="0 0 100 100" fill="none">
|
|
<circle cx="50" cy="50" r="45" stroke="#f3f4f6" stroke-width="6"/>
|
|
<circle cx="50" cy="50" r="38" stroke="var(--primary)" stroke-width="2" stroke-dasharray="5 5"/>
|
|
<rect x="15" y="47" width="70" height="6" rx="3" fill="#f3f4f6"/>
|
|
<rect x="47" y="50" width="6" height="35" rx="3" fill="#f3f4f6"/>
|
|
<circle cx="50" cy="50" r="10" fill="#0b0f19" stroke="#f3f4f6" stroke-width="3"/>
|
|
</svg>
|
|
</div>
|
|
<div class="keys-grid">
|
|
<div class="key-btn empty"></div>
|
|
<div id="keyW" class="key-btn">W</div>
|
|
<div class="key-btn empty"></div>
|
|
<div id="keyA" class="key-btn">A</div>
|
|
<div id="keyS" class="key-btn">S</div>
|
|
<div id="keyD" class="key-btn">D</div>
|
|
</div>
|
|
|
|
<div class="control-extra" style="display: flex; gap: 10px; margin-top: 15px; justify-content: space-around; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; width: 100%;">
|
|
<div class="gear-indicator" style="text-align: center;">
|
|
<div style="font-size: 0.8rem; color: #8a8d9a;">GEAR</div>
|
|
<div style="display: flex; align-items: center; gap: 5px; margin-top: 4px;">
|
|
<button id="btnGearDown" class="btn" style="padding: 2px 8px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">-</button>
|
|
<b id="lblGear" style="font-size: 1.3rem; min-width: 15px; display: inline-block;">N</b>
|
|
<button id="btnGearUp" class="btn" style="padding: 2px 8px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">+</button>
|
|
</div>
|
|
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(Z / X)</div>
|
|
</div>
|
|
<div class="sys-toggle" style="text-align: center;">
|
|
<div style="font-size: 0.8rem; color: #8a8d9a;">DRS</div>
|
|
<button id="btnDrs" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">Toggle</button>
|
|
<div id="lblDrs" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ef4444;">OFF</div>
|
|
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(Q)</div>
|
|
</div>
|
|
<div class="sys-toggle" style="text-align: center;">
|
|
<div style="font-size: 0.8rem; color: #8a8d9a;">PIT LIMIT</div>
|
|
<button id="btnPit" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">Toggle</button>
|
|
<div id="lblPit" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ef4444;">OFF</div>
|
|
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(E)</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 15px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; width: 100%;">
|
|
<span style="font-size: 0.8rem; color: #8a8d9a;">Пакет управления (JSON):</span>
|
|
<pre id="ctrlJson" style="font-family: monospace; font-size: 0.75rem; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.05); padding: 8px; border-radius: 4px; color: #00ff66; margin: 6px 0 0 0; overflow-x: auto; max-height: 250px; text-align: left; line-height: 1.2;">{}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: Telemetry & Controls Panel -->
|
|
<div class="glass-panel" style="display: flex; flex-direction: column; gap: 1.5rem;">
|
|
<div>
|
|
<h2>
|
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
|
Session Control
|
|
</h2>
|
|
<div class="auth-bar">
|
|
<input type="text" id="driverIdInput" placeholder="Enter Driver ID (e.g. ACE)" value="ACE">
|
|
<button id="connectBtn">Connect</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h2>
|
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z"/></svg>
|
|
Real-time Telemetry
|
|
</h2>
|
|
<div class="telemetry-grid">
|
|
<div class="telemetry-card">
|
|
<div class="telemetry-label">Speed</div>
|
|
<div id="telSpeed" class="telemetry-value">0.00 <span style="font-size: 0.8rem; font-weight: normal;">m/s</span></div>
|
|
</div>
|
|
<div class="telemetry-card">
|
|
<div class="telemetry-label">Ping Latency</div>
|
|
<div id="telPing" class="telemetry-value">0 <span style="font-size: 0.8rem; font-weight: normal;">ms</span></div>
|
|
</div>
|
|
<div class="telemetry-card">
|
|
<div class="telemetry-label">Car ID</div>
|
|
<div id="telDevice" class="telemetry-value">-</div>
|
|
</div>
|
|
<div class="telemetry-card">
|
|
<div class="telemetry-label">Position (X, Y)</div>
|
|
<div id="telPos" class="telemetry-value">0.0, 0.0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="flex: 1; display: flex; flex-direction: column;">
|
|
<h2>
|
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
|
Console Log
|
|
</h2>
|
|
<div id="consoleLog" class="console">
|
|
<div class="console-entry"><span class="console-time">[18:58:00]</span> Welcome to x0gp Cockpit. Enter Driver ID and click Connect to initialize WebRTC.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<footer>
|
|
x0gp · Pure Go WebRTC 1-to-1 Proxy System
|
|
</footer>
|
|
|
|
<script>
|
|
const statusDot = document.getElementById('statusDot');
|
|
const statusText = document.getElementById('statusText');
|
|
const driverIdInput = document.getElementById('driverIdInput');
|
|
const connectBtn = document.getElementById('connectBtn');
|
|
const videoFeed = document.getElementById('videoFeed');
|
|
const videoPlaceholder = document.getElementById('videoPlaceholder');
|
|
const consoleLog = document.getElementById('consoleLog');
|
|
const steeringWheel = document.getElementById('steeringWheel');
|
|
|
|
// Telemetry DOM
|
|
const telSpeed = document.getElementById('telSpeed');
|
|
const telPing = document.getElementById('telPing');
|
|
const telDevice = document.getElementById('telDevice');
|
|
const telPos = document.getElementById('telPos');
|
|
|
|
let pc = null;
|
|
let dataChannel = null;
|
|
let videoChannel = null;
|
|
let sendControlsInterval = null;
|
|
let activeKeys = { w: false, a: false, s: false, d: false };
|
|
let currentSteer = 0; // -1 to 1
|
|
let currentThrottle = 0; // 0 to 1
|
|
let currentBrake = 0; // 0 to 1
|
|
let currentGear = 0; // 0: N, -1: R, 1: D
|
|
let drs = false;
|
|
let pitLimiter = false;
|
|
let buttons = 0;
|
|
let pingsReceived = 0;
|
|
let latencyStart = 0;
|
|
|
|
function log(message, type = 'system') {
|
|
const time = new Date().toTimeString().split(' ')[0];
|
|
const div = document.createElement('div');
|
|
div.className = 'console-entry';
|
|
|
|
let prefix = '';
|
|
if (type === 'in') prefix = ' <span class="console-in"><-</span> ';
|
|
if (type === 'out') prefix = ' <span class="console-out">-></span> ';
|
|
|
|
div.innerHTML = `<span class="console-time">[${time}]</span>${prefix}${message}`;
|
|
consoleLog.appendChild(div);
|
|
consoleLog.scrollTop = consoleLog.scrollHeight;
|
|
}
|
|
|
|
// Keyboard event listeners
|
|
window.addEventListener('keydown', (e) => {
|
|
const key = e.key.toLowerCase();
|
|
if (activeKeys.hasOwnProperty(key)) {
|
|
activeKeys[key] = true;
|
|
updateKeyVisuals();
|
|
}
|
|
if (key === 'q') {
|
|
drs = !drs;
|
|
updateKeyVisuals();
|
|
}
|
|
if (key === 'e') {
|
|
pitLimiter = !pitLimiter;
|
|
updateKeyVisuals();
|
|
}
|
|
if (key === 'z') {
|
|
currentGear = Math.max(-1, currentGear - 1);
|
|
updateKeyVisuals();
|
|
}
|
|
if (key === 'x') {
|
|
currentGear = Math.min(1, currentGear + 1);
|
|
updateKeyVisuals();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('keyup', (e) => {
|
|
const key = e.key.toLowerCase();
|
|
if (activeKeys.hasOwnProperty(key)) {
|
|
activeKeys[key] = false;
|
|
updateKeyVisuals();
|
|
}
|
|
});
|
|
|
|
function updateKeyVisuals() {
|
|
document.getElementById('keyW').classList.toggle('active', activeKeys.w);
|
|
document.getElementById('keyA').classList.toggle('active', activeKeys.a);
|
|
document.getElementById('keyS').classList.toggle('active', activeKeys.s);
|
|
document.getElementById('keyD').classList.toggle('active', activeKeys.d);
|
|
|
|
// Calculate wheel rotation based on keys
|
|
let targetRotation = 0;
|
|
if (activeKeys.a) targetRotation = -45;
|
|
if (activeKeys.d) targetRotation = 45;
|
|
steeringWheel.style.transform = `rotate(${targetRotation}deg)`;
|
|
|
|
// Compute values
|
|
currentThrottle = activeKeys.w ? 1.0 : 0.0;
|
|
currentBrake = activeKeys.s ? 1.0 : 0.0;
|
|
currentSteer = 0.0;
|
|
if (activeKeys.a) currentSteer = -1.0;
|
|
if (activeKeys.d) currentSteer = 1.0;
|
|
|
|
buttons = 0;
|
|
if (drs) buttons |= 1;
|
|
if (pitLimiter) buttons |= 2;
|
|
|
|
// Update UI Gear/DRS/Pit if elements exist
|
|
const lblGear = document.getElementById('lblGear');
|
|
if (lblGear) {
|
|
let gName = 'N';
|
|
if (currentGear === -1) gName = 'R';
|
|
if (currentGear === 1) gName = 'D';
|
|
lblGear.innerText = gName;
|
|
}
|
|
const lblDrs = document.getElementById('lblDrs');
|
|
if (lblDrs) {
|
|
lblDrs.innerText = drs ? 'ON' : 'OFF';
|
|
lblDrs.style.color = drs ? '#00ff66' : '#ef4444';
|
|
}
|
|
const lblPit = document.getElementById('lblPit');
|
|
if (lblPit) {
|
|
lblPit.innerText = pitLimiter ? 'ON' : 'OFF';
|
|
lblPit.style.color = pitLimiter ? '#00ff66' : '#ef4444';
|
|
}
|
|
|
|
const ctrlJson = document.getElementById('ctrlJson');
|
|
if (ctrlJson) {
|
|
const steerByte = Math.round((currentSteer + 1.0) * 100);
|
|
const throttleByte = Math.round(currentThrottle * 100);
|
|
const brakeByte = Math.round(currentBrake * 100);
|
|
const gearByte = currentGear === -1 ? 0 : (currentGear === 1 ? 2 : 1);
|
|
|
|
ctrlJson.innerText = JSON.stringify({
|
|
sent_json: {
|
|
steering: currentSteer,
|
|
throttle: currentThrottle,
|
|
brake: currentBrake,
|
|
gear: currentGear,
|
|
buttons: buttons
|
|
},
|
|
binary_packet_bytes: {
|
|
byte0_steer: `0x${steerByte.toString(16).toUpperCase().padStart(2, '0')} (${steerByte})`,
|
|
byte1_throttle: `0x${throttleByte.toString(16).toUpperCase().padStart(2, '0')} (${throttleByte}%)`,
|
|
byte2_brake: `0x${brakeByte.toString(16).toUpperCase().padStart(2, '0')} (${brakeByte}%)`,
|
|
byte3_gear: `0x${gearByte.toString(16).toUpperCase().padStart(2, '0')} (${gearByte === 0 ? 'R' : (gearByte === 2 ? 'D' : 'N')})`,
|
|
byte4_flags: `0x${buttons.toString(16).toUpperCase().padStart(2, '0')} (DRS=${drs ? 1 : 0}, Pit=${pitLimiter ? 1 : 0})`
|
|
}
|
|
}, null, 2);
|
|
}
|
|
|
|
// Log control changes to UI console
|
|
log(`Controls updated: throttle=${currentThrottle}, steering=${currentSteer}, brake=${currentBrake}, gear=${currentGear}, buttons=${buttons}`, 'out');
|
|
}
|
|
|
|
// Click listeners for the custom controls
|
|
document.getElementById('btnGearDown').addEventListener('click', () => {
|
|
currentGear = Math.max(-1, currentGear - 1);
|
|
updateKeyVisuals();
|
|
});
|
|
document.getElementById('btnGearUp').addEventListener('click', () => {
|
|
currentGear = Math.min(1, currentGear + 1);
|
|
updateKeyVisuals();
|
|
});
|
|
document.getElementById('btnDrs').addEventListener('click', () => {
|
|
drs = !drs;
|
|
updateKeyVisuals();
|
|
});
|
|
document.getElementById('btnPit').addEventListener('click', () => {
|
|
pitLimiter = !pitLimiter;
|
|
updateKeyVisuals();
|
|
});
|
|
|
|
connectBtn.addEventListener('click', () => {
|
|
if (pc) {
|
|
disconnectCockpit();
|
|
} else {
|
|
connectCockpit();
|
|
}
|
|
});
|
|
|
|
async function connectCockpit() {
|
|
const driverId = driverIdInput.value.trim().toUpperCase();
|
|
if (!driverId) {
|
|
alert('Please enter a valid Driver ID');
|
|
return;
|
|
}
|
|
|
|
log(`Initializing WebRTC Connection for Driver: ${driverId}...`);
|
|
updateStatus('connecting', 'Connecting...');
|
|
|
|
connectBtn.disabled = true;
|
|
|
|
try {
|
|
// 1. Create PeerConnection
|
|
pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
});
|
|
|
|
// 2. Create Data Channels (Client Initiated)
|
|
dataChannel = pc.createDataChannel('data', { ordered: true });
|
|
videoChannel = pc.createDataChannel('video', { ordered: false });
|
|
|
|
setupDataChannel(dataChannel);
|
|
setupVideoChannel(videoChannel);
|
|
|
|
// 3. Create SDP Offer
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
// 4. Wait for ICE gathering to complete (Vanilla ICE)
|
|
log('Gathering ICE Candidates...');
|
|
await new Promise((resolve) => {
|
|
if (pc.iceGatheringState === 'complete') {
|
|
resolve();
|
|
} else {
|
|
function checkState() {
|
|
if (pc.iceGatheringState === 'complete') {
|
|
pc.removeEventListener('icegatheringstatechange', checkState);
|
|
resolve();
|
|
}
|
|
}
|
|
pc.addEventListener('icegatheringstatechange', checkState);
|
|
}
|
|
});
|
|
|
|
// 5. Post SDP Offer to Server
|
|
const signalingUrl = `${window.location.protocol}//localhost:8080/api/webrtc/connect`;
|
|
log(`Sending SDP Offer to ${signalingUrl}...`);
|
|
|
|
const response = await fetch(signalingUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sdp: pc.localDescription.sdp,
|
|
type: 'offer',
|
|
driver_id: driverId
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await response.text());
|
|
}
|
|
|
|
const answer = await response.json();
|
|
log('SDP Answer received from server. Establishing WebRTC channel...');
|
|
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
|
|
} catch (err) {
|
|
log(`WebRTC connection failed: ${err.message}`, 'system');
|
|
updateStatus('disconnected', 'Failed');
|
|
disconnectCockpit();
|
|
} finally {
|
|
connectBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function disconnectCockpit() {
|
|
log('Disconnecting WebRTC Cockpit...');
|
|
|
|
if (sendControlsInterval) {
|
|
clearInterval(sendControlsInterval);
|
|
sendControlsInterval = null;
|
|
}
|
|
|
|
if (dataChannel) {
|
|
dataChannel.close();
|
|
dataChannel = null;
|
|
}
|
|
|
|
if (videoChannel) {
|
|
videoChannel.close();
|
|
videoChannel = null;
|
|
}
|
|
|
|
if (pc) {
|
|
pc.close();
|
|
pc = null;
|
|
}
|
|
|
|
videoFeed.style.display = 'none';
|
|
videoPlaceholder.style.display = 'flex';
|
|
updateStatus('disconnected', 'Disconnected');
|
|
connectBtn.innerText = 'Connect';
|
|
connectBtn.classList.remove('disconnect');
|
|
|
|
// Reset Telemetry
|
|
telSpeed.innerHTML = '0.00 <span style="font-size: 0.8rem; font-weight: normal;">m/s</span>';
|
|
telPing.innerHTML = '0 <span style="font-size: 0.8rem; font-weight: normal;">ms</span>';
|
|
telDevice.innerHTML = '-';
|
|
telPos.innerHTML = '0.0, 0.0';
|
|
}
|
|
|
|
function setupDataChannel(dc) {
|
|
dc.onopen = () => {
|
|
log(`Bidirectional DataChannel ('${dc.label}') is OPEN`, 'system');
|
|
updateStatus('connected', 'Connected');
|
|
connectBtn.innerText = 'Disconnect';
|
|
connectBtn.classList.add('disconnect');
|
|
|
|
// Start sending steering controls at 30Hz
|
|
sendControlsInterval = setInterval(sendControls, 33);
|
|
};
|
|
|
|
dc.onclose = () => {
|
|
log(`DataChannel ('${dc.label}') is CLOSED`, 'system');
|
|
disconnectCockpit();
|
|
};
|
|
|
|
dc.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
|
|
if (msg.type === 'telemetry') {
|
|
// Update Telemetry Panel
|
|
telSpeed.innerHTML = `${msg.speed.toFixed(2)} <span style="font-size: 0.8rem; font-weight: normal;">m/s</span>`;
|
|
telDevice.innerHTML = msg.car_id || '-';
|
|
telPos.innerHTML = `${msg.x.toFixed(1)}, ${msg.y.toFixed(1)}`;
|
|
|
|
// Measure RTT
|
|
const currentMs = Date.now();
|
|
const latency = currentMs - msg.timestamp;
|
|
telPing.innerHTML = `${latency} <span style="font-size: 0.8rem; font-weight: normal;">ms</span>`;
|
|
|
|
if (pingsReceived % 5 === 0) {
|
|
log(`Telemetry RX: Speed=${msg.speed.toFixed(2)}m/s Car=${msg.car_id || '-'}`, 'in');
|
|
}
|
|
pingsReceived++;
|
|
} else if (msg.type === 'ack') {
|
|
// Log ACK periodically (every 30 frames) to verify loopback
|
|
if (pingsReceived % 30 === 0) {
|
|
log(`ACK RX from server: applied=${msg.applied}`, 'in');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log(`DataChannel Error: ${err.message}`, 'system');
|
|
}
|
|
};
|
|
}
|
|
|
|
function setupVideoChannel(dc) {
|
|
dc.binaryType = 'arraybuffer';
|
|
dc.onopen = () => {
|
|
log(`Video DataChannel ('${dc.label}') is OPEN`, 'system');
|
|
videoPlaceholder.style.display = 'none';
|
|
videoFeed.style.display = 'block';
|
|
};
|
|
|
|
dc.onclose = () => {
|
|
log(`Video DataChannel ('${dc.label}') is CLOSED`, 'system');
|
|
};
|
|
|
|
dc.onmessage = (event) => {
|
|
// Binary JPEG frames received
|
|
const blob = new Blob([event.data], { type: 'image/jpeg' });
|
|
const url = URL.createObjectURL(blob);
|
|
videoFeed.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
videoFeed.src = url;
|
|
};
|
|
}
|
|
|
|
function sendControls() {
|
|
if (dataChannel && dataChannel.readyState === 'open') {
|
|
const payload = {
|
|
throttle: currentThrottle,
|
|
steering: currentSteer,
|
|
brake: currentBrake,
|
|
gear: currentGear,
|
|
buttons: buttons,
|
|
timestamp: Date.now()
|
|
};
|
|
dataChannel.send(JSON.stringify(payload));
|
|
}
|
|
}
|
|
|
|
function updateStatus(state, label) {
|
|
statusText.innerText = label;
|
|
statusDot.className = 'status-dot';
|
|
if (state === 'connected') statusDot.classList.add('connected');
|
|
if (state === 'connecting') statusDot.classList.add('connecting');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|