1083 lines
38 KiB
HTML
1083 lines
38 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Virtual Screen & Speakers - MCP Companion</title>
|
|
<!-- Modern Google Fonts -->
|
|
<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=Fira+Code:wght@400;600&family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg-color: #0f172a;
|
|
--card-bg: rgba(30, 41, 59, 0.7);
|
|
--border-color: rgba(255, 255, 255, 0.08);
|
|
--primary-glow: rgba(99, 102, 241, 0.15);
|
|
--secondary-glow: rgba(236, 72, 153, 0.15);
|
|
--accent-violet: #6366f1;
|
|
--accent-pink: #ec4899;
|
|
--text-main: #f8fafc;
|
|
--text-muted: #94a3b8;
|
|
--font-outfit: 'Outfit', sans-serif;
|
|
--font-mono: 'Fira Code', monospace;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font-outfit);
|
|
background-color: var(--bg-color);
|
|
background-image:
|
|
radial-gradient(circle at 10% 20%, var(--primary-glow) 0%, transparent 40%),
|
|
radial-gradient(circle at 90% 80%, var(--secondary-glow) 0%, transparent 40%);
|
|
background-attachment: fixed;
|
|
color: var(--text-main);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Top navigation header */
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1.5rem 2rem;
|
|
background: rgba(15, 23, 42, 0.6);
|
|
backdrop-filter: blur(8px);
|
|
border-bottom: 1px solid var(--border-color);
|
|
z-index: 100;
|
|
}
|
|
|
|
.logo {
|
|
font-weight: 800;
|
|
font-size: 1.5rem;
|
|
letter-spacing: -0.025em;
|
|
background: linear-gradient(135deg, var(--accent-violet) 0%, var(--accent-pink) 100%);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.logo-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
background-color: var(--accent-pink);
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
box-shadow: 0 0 10px var(--accent-pink);
|
|
}
|
|
|
|
.status-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: rgba(255, 255, 255, 0.04);
|
|
border: 1px solid var(--border-color);
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
background-color: #ef4444; /* Default disconnected red */
|
|
border-radius: 50%;
|
|
box-shadow: 0 0 8px #ef4444;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.status-dot.connected {
|
|
background-color: #10b981; /* Connected green */
|
|
box-shadow: 0 0 8px #10b981;
|
|
}
|
|
|
|
/* Dashboard Grid Layout */
|
|
.dashboard-container {
|
|
display: grid;
|
|
grid-template-columns: 320px 1fr 340px;
|
|
gap: 2rem;
|
|
padding: 2rem;
|
|
flex-grow: 1;
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
width: 100%;
|
|
}
|
|
|
|
@media (max-width: 1200px) {
|
|
.dashboard-container {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
/* Card styles */
|
|
.glass-card {
|
|
background: var(--card-bg);
|
|
backdrop-filter: blur(16px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 20px;
|
|
padding: 1.5rem;
|
|
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3);
|
|
transition: border-color 0.3s ease;
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.glass-card:hover {
|
|
border-color: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
letter-spacing: -0.01em;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.card-subtitle {
|
|
font-size: 0.875rem;
|
|
color: var(--text-muted);
|
|
margin-top: -0.5rem;
|
|
}
|
|
|
|
/* Virtual Screen Bezel and Canvas */
|
|
.screen-column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.monitor-bezel {
|
|
background: #1e293b;
|
|
border-radius: 24px;
|
|
padding: 1rem;
|
|
border: 3px solid #334155;
|
|
box-shadow:
|
|
0 25px 50px -12px rgba(0, 0, 0, 0.5),
|
|
inset 0 0 20px rgba(0, 0, 0, 0.6);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.monitor-bezel::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: linear-gradient(135deg, rgba(255,255,255,0.03) 0%, transparent 60%);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.virtual-canvas {
|
|
background-color: #0f172a;
|
|
border-radius: 12px;
|
|
width: 100%;
|
|
aspect-ratio: 4 / 3;
|
|
max-width: 800px;
|
|
display: block;
|
|
box-shadow: inset 0 0 15px rgba(0,0,0,0.9);
|
|
image-rendering: pixelated;
|
|
}
|
|
|
|
/* Screen Reflection and CRT effects */
|
|
.crt-scanlines {
|
|
position: absolute;
|
|
top: 1rem;
|
|
left: 1rem;
|
|
right: 1rem;
|
|
bottom: 1rem;
|
|
border-radius: 12px;
|
|
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%);
|
|
background-size: 100% 4px;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
}
|
|
|
|
/* Custom UI sliders and dials */
|
|
.sensor-control {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.slider-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.slider-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.slider-name {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.slider-value {
|
|
font-family: var(--font-mono);
|
|
color: var(--accent-violet);
|
|
}
|
|
|
|
input[type="range"] {
|
|
-webkit-appearance: none;
|
|
width: 100%;
|
|
height: 6px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 9999px;
|
|
outline: none;
|
|
transition: background 0.3s;
|
|
}
|
|
|
|
input[type="range"]::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, var(--accent-violet), var(--accent-pink));
|
|
cursor: pointer;
|
|
box-shadow: 0 0 10px rgba(99, 102, 241, 0.5);
|
|
transition: transform 0.1s ease;
|
|
}
|
|
|
|
input[type="range"]::-webkit-slider-thumb:hover {
|
|
transform: scale(1.2);
|
|
}
|
|
|
|
/* LED Ring Display */
|
|
.led-panel {
|
|
align-items: center;
|
|
}
|
|
|
|
.led-ring-container {
|
|
position: relative;
|
|
width: 200px;
|
|
height: 200px;
|
|
margin: 1.5rem auto;
|
|
border-radius: 50%;
|
|
background: rgba(0,0,0,0.2);
|
|
border: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.led-pixel {
|
|
position: absolute;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background-color: #334155;
|
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.6);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
/* Audio Visualization */
|
|
.audio-wave-container {
|
|
width: 100%;
|
|
height: 60px;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border-color);
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 3px;
|
|
padding: 0 10px;
|
|
}
|
|
|
|
.audio-bar {
|
|
width: 4px;
|
|
height: 10%;
|
|
background: linear-gradient(to top, var(--accent-violet), var(--accent-pink));
|
|
border-radius: 2px;
|
|
transition: height 0.1s ease;
|
|
}
|
|
|
|
/* Record Panel & Controls */
|
|
.mic-btn-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.75rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.mic-circle {
|
|
width: 72px;
|
|
height: 72px;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 2px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
position: relative;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.mic-circle svg {
|
|
width: 32px;
|
|
height: 32px;
|
|
fill: var(--text-muted);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.mic-circle.recording {
|
|
border-color: #ef4444;
|
|
background: rgba(239, 68, 68, 0.15);
|
|
box-shadow: 0 0 25px rgba(239, 68, 68, 0.5);
|
|
animation: pulse-red 1.5s infinite;
|
|
}
|
|
|
|
.mic-circle.recording svg {
|
|
fill: #ef4444;
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
.recording-status-text {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.recording-status-text.active {
|
|
color: #ef4444;
|
|
}
|
|
|
|
@keyframes pulse-red {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
|
}
|
|
70% {
|
|
box-shadow: 0 0 0 15px rgba(239, 68, 68, 0);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
|
|
}
|
|
}
|
|
|
|
/* Details list */
|
|
.info-grid {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 0.75rem 1.25rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.info-label {
|
|
color: var(--text-muted);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.info-value {
|
|
text-align: right;
|
|
font-family: var(--font-mono);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Fullscreen Overlay for disconnecting */
|
|
.overlay-reconnect {
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(15, 23, 42, 0.85);
|
|
backdrop-filter: blur(12px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 200;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.3s ease;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.overlay-reconnect.visible {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.spinner {
|
|
width: 50px;
|
|
height: 50px;
|
|
border: 3px solid rgba(255, 255, 255, 0.05);
|
|
border-top: 3px solid var(--accent-violet);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
box-shadow: 0 0 15px rgba(99, 102, 241, 0.3);
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
|
|
.overlay-text {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
letter-spacing: -0.01rem;
|
|
}
|
|
|
|
/* LED Animation keyframes */
|
|
@keyframes led-breath {
|
|
0%, 100% { opacity: 0.3; filter: drop-shadow(0 0 2px var(--led-color)); }
|
|
50% { opacity: 1.0; filter: drop-shadow(0 0 12px var(--led-color)); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="logo">
|
|
<span class="logo-dot"></span>
|
|
Virtual Screen & Speakers
|
|
</div>
|
|
<div class="status-badge">
|
|
<span id="statusDot" class="status-dot"></span>
|
|
<span id="statusText">Disconnected</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="dashboard-container">
|
|
<!-- LEFT COLUMN: Sensors & System Info -->
|
|
<div class="glass-card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Virtual Sensors</h2>
|
|
</div>
|
|
<div class="card-subtitle">Adjust sliders to simulate local device telemetry</div>
|
|
|
|
<div class="sensor-control">
|
|
<div class="slider-group">
|
|
<div class="slider-header">
|
|
<span class="slider-name">Temperature</span>
|
|
<span id="tempVal" class="slider-value">22.5 °C</span>
|
|
</div>
|
|
<input type="range" id="tempSlider" min="0" max="50" step="0.1" value="22.5">
|
|
</div>
|
|
|
|
<div class="slider-group">
|
|
<div class="slider-header">
|
|
<span class="slider-name">Humidity</span>
|
|
<span id="humVal" class="slider-value">45 %</span>
|
|
</div>
|
|
<input type="range" id="humSlider" min="0" max="100" step="1" value="45">
|
|
</div>
|
|
|
|
<div class="slider-group">
|
|
<div class="slider-header">
|
|
<span class="slider-name">Ambient Light</span>
|
|
<span id="lightVal" class="slider-value">350 lx</span>
|
|
</div>
|
|
<input type="range" id="lightSlider" min="0" max="2000" step="10" value="350">
|
|
</div>
|
|
</div>
|
|
|
|
<hr style="border: 0; border-top: 1px solid var(--border-color); margin: 0.5rem 0;">
|
|
|
|
<div class="card-header">
|
|
<h2 class="card-title">System telemetry</h2>
|
|
</div>
|
|
<div class="info-grid">
|
|
<div class="info-label">Virtual Client</div>
|
|
<div id="infoClient" class="info-value">Browser Tab</div>
|
|
|
|
<div class="info-label">WebSocket URL</div>
|
|
<div id="infoWsUrl" class="info-value">ws://...</div>
|
|
|
|
<div class="info-label">Latency</div>
|
|
<div id="infoLatency" class="info-value">0 ms</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CENTER COLUMN: Screen -->
|
|
<div class="screen-column">
|
|
<div class="glass-card" style="flex-grow: 1; justify-content: center;">
|
|
<div class="card-header">
|
|
<div>
|
|
<h2 class="card-title">Device Display</h2>
|
|
<div class="card-subtitle">800x600 High-Res Virtual Frame Buffer</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="monitor-bezel">
|
|
<canvas id="virtualCanvas" class="virtual-canvas" width="800" height="600"></canvas>
|
|
<div class="crt-scanlines"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RIGHT COLUMN: LEDs, Audio, & Mic -->
|
|
<div class="glass-card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">NeoPixel LED Ring</h2>
|
|
</div>
|
|
<div class="card-subtitle" id="ledModeText">Mode: Off</div>
|
|
|
|
<div class="led-ring-container" id="ledRing">
|
|
<!-- 12 pixels inserted by JS -->
|
|
</div>
|
|
|
|
<hr style="border: 0; border-top: 1px solid var(--border-color); margin: 0.5rem 0;">
|
|
|
|
<div class="card-header">
|
|
<h2 class="card-title">Audio & Microphone</h2>
|
|
</div>
|
|
|
|
<div class="audio-wave-container" id="audioVisualizer">
|
|
<!-- 15 bars for visualization -->
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
<div class="audio-bar"></div>
|
|
</div>
|
|
|
|
<div class="mic-btn-container">
|
|
<div class="mic-circle" id="micButton">
|
|
<svg viewBox="0 0 24 24">
|
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>
|
|
</svg>
|
|
</div>
|
|
<span id="recordingStatus" class="recording-status-text">Microphone Idle</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reconnection Overlay -->
|
|
<div class="overlay-reconnect" id="reconnectOverlay">
|
|
<div class="spinner"></div>
|
|
<div class="overlay-text">Establishing WebSocket Connection...</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Setup Canvas
|
|
const canvas = document.getElementById('virtualCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Initialize Canvas with default deep background
|
|
function clearCanvasLocal(colorVal) {
|
|
ctx.fillStyle = parseColorLocal(colorVal);
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
function parseColorLocal(colorVal) {
|
|
if (colorVal === 0 || colorVal === '0') return '#ffffff';
|
|
if (colorVal === 1 || colorVal === '1') return '#000000';
|
|
if (!colorVal) return '#0f172a';
|
|
return colorVal;
|
|
}
|
|
|
|
clearCanvasLocal();
|
|
|
|
// Setup 12-Pixel LED Ring
|
|
const ledRing = document.getElementById('ledRing');
|
|
const ledCount = 12;
|
|
const ledPixels = [];
|
|
|
|
for (let i = 0; i < ledCount; i++) {
|
|
const pixel = document.createElement('div');
|
|
pixel.className = 'led-pixel';
|
|
const angle = (i * 360 / ledCount) * (Math.PI / 180);
|
|
const radius = 70; // px
|
|
const x = Math.round(92 + radius * Math.cos(angle));
|
|
const y = Math.round(92 + radius * Math.sin(angle));
|
|
pixel.style.left = `${x}px`;
|
|
pixel.style.top = `${y}px`;
|
|
ledRing.appendChild(pixel);
|
|
ledPixels.push(pixel);
|
|
}
|
|
|
|
// Web Audio Context
|
|
let audioCtx = null;
|
|
function getAudioContext() {
|
|
if (!audioCtx) {
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
if (audioCtx.state === 'suspended') {
|
|
audioCtx.resume();
|
|
}
|
|
return audioCtx;
|
|
}
|
|
|
|
// Audio Visualization logic
|
|
const audioVisualizer = document.getElementById('audioVisualizer');
|
|
const visualizerBars = Array.from(audioVisualizer.querySelectorAll('.audio-bar'));
|
|
let visualizationInterval = null;
|
|
|
|
function triggerVisualizer(durationMs) {
|
|
clearInterval(visualizationInterval);
|
|
let elapsed = 0;
|
|
visualizationInterval = setInterval(() => {
|
|
visualizerBars.forEach(bar => {
|
|
const height = Math.random() * 85 + 10;
|
|
bar.style.height = `${height}%`;
|
|
});
|
|
elapsed += 100;
|
|
if (elapsed >= durationMs) {
|
|
clearInterval(visualizationInterval);
|
|
resetVisualizer();
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
function resetVisualizer() {
|
|
visualizerBars.forEach(bar => {
|
|
bar.style.height = '10%';
|
|
});
|
|
}
|
|
|
|
// WebSocket Management
|
|
let socket = null;
|
|
const statusDot = document.getElementById('statusDot');
|
|
const statusText = document.getElementById('statusText');
|
|
const infoWsUrl = document.getElementById('infoWsUrl');
|
|
const infoLatency = document.getElementById('infoLatency');
|
|
const reconnectOverlay = document.getElementById('reconnectOverlay');
|
|
|
|
// Populate system details
|
|
const infoClient = document.getElementById('infoClient');
|
|
infoClient.textContent = `${navigator.platform} / ${navigator.appName}`;
|
|
|
|
function connectWebSocket() {
|
|
const loc = window.location;
|
|
let wsUri = loc.protocol === "https:" ? "wss:" : "ws:";
|
|
wsUri += "//" + loc.host + "/ws";
|
|
infoWsUrl.textContent = wsUri;
|
|
|
|
statusText.textContent = "Connecting...";
|
|
reconnectOverlay.classList.add('visible');
|
|
|
|
socket = new WebSocket(wsUri);
|
|
|
|
socket.onopen = function() {
|
|
console.log("WebSocket connected.");
|
|
statusDot.classList.add('connected');
|
|
statusText.textContent = "Connected";
|
|
reconnectOverlay.classList.remove('visible');
|
|
|
|
// Send initial telemetry values to server
|
|
sendSensorUpdate();
|
|
};
|
|
|
|
socket.onclose = function() {
|
|
console.log("WebSocket disconnected. Retrying...");
|
|
statusDot.classList.remove('connected');
|
|
statusText.textContent = "Disconnected";
|
|
reconnectOverlay.classList.add('visible');
|
|
setTimeout(connectWebSocket, 2000);
|
|
};
|
|
|
|
socket.onerror = function(err) {
|
|
console.error("WebSocket error:", err);
|
|
};
|
|
|
|
socket.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
const action = data.action;
|
|
|
|
console.log("Received message:", data);
|
|
|
|
if (data.event === "pong") {
|
|
const rtt = Date.now() - data.t;
|
|
infoLatency.textContent = rtt + " ms";
|
|
return;
|
|
}
|
|
|
|
if (action === 'clear') {
|
|
clearCanvasLocal(data.color);
|
|
}
|
|
else if (action === 'draw_text') {
|
|
ctx.font = `${data.size || 16}px var(--font-outfit)`;
|
|
ctx.fillStyle = parseColorLocal(data.color || 'white');
|
|
ctx.fillText(data.text, data.x, data.y);
|
|
}
|
|
else if (action === 'draw_shape') {
|
|
ctx.fillStyle = parseColorLocal(data.color || 'white');
|
|
ctx.strokeStyle = parseColorLocal(data.color || 'white');
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
|
|
if (data.shape === 'rect') {
|
|
if (data.fill) {
|
|
ctx.fillRect(data.x, data.y, data.w, data.h);
|
|
} else {
|
|
ctx.strokeRect(data.x, data.y, data.w, data.h);
|
|
}
|
|
} else if (data.shape === 'circle') {
|
|
const radius = Math.min(data.w, data.h) / 2;
|
|
ctx.arc(data.x + radius, data.y + radius, radius, 0, 2 * Math.PI);
|
|
if (data.fill) {
|
|
ctx.fill();
|
|
} else {
|
|
ctx.stroke();
|
|
}
|
|
} else if (data.shape === 'line') {
|
|
ctx.moveTo(data.x, data.y);
|
|
ctx.lineTo(data.x + data.w, data.y + data.h);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
else if (action === 'draw_image') {
|
|
const img = new Image();
|
|
img.onload = function() {
|
|
const w = data.w || img.width;
|
|
const h = data.h || img.height;
|
|
ctx.drawImage(img, data.x || 0, data.y || 0, w, h);
|
|
};
|
|
img.src = data.image_base64.startsWith('data:') ? data.image_base64 : 'data:image/png;base64,' + data.image_base64;
|
|
}
|
|
else if (action === 'set_led') {
|
|
updateLedRing(data.r, data.g, data.b, data.mode);
|
|
}
|
|
else if (action === 'play_tone') {
|
|
playToneLocal(data.frequency, data.duration, data.volume);
|
|
}
|
|
else if (action === 'play_audio') {
|
|
playAudioLocal(data.audio, data.volume);
|
|
}
|
|
else if (action === 'record_voice') {
|
|
startVoiceRecording(data.duration_sec, data.recording_id);
|
|
}
|
|
else if (action === 'ping') {
|
|
socket.send(JSON.stringify({ event: "pong", id: data.id }));
|
|
}
|
|
};
|
|
}
|
|
|
|
// Start Connection
|
|
connectWebSocket();
|
|
|
|
// Virtual Sensor Sliders
|
|
const tempSlider = document.getElementById('tempSlider');
|
|
const humSlider = document.getElementById('humSlider');
|
|
const lightSlider = document.getElementById('lightSlider');
|
|
|
|
const tempVal = document.getElementById('tempVal');
|
|
const humVal = document.getElementById('humVal');
|
|
const lightVal = document.getElementById('lightVal');
|
|
|
|
function sendSensorUpdate() {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
const temp = parseFloat(tempSlider.value);
|
|
const hum = parseInt(humSlider.value);
|
|
const light = parseInt(lightSlider.value);
|
|
|
|
tempVal.textContent = temp.toFixed(1) + " °C";
|
|
humVal.textContent = hum + " %";
|
|
lightVal.textContent = light + " lx";
|
|
|
|
socket.send(JSON.stringify({
|
|
event: "sensor_update",
|
|
temperature: temp,
|
|
humidity: hum,
|
|
light: light
|
|
}));
|
|
}
|
|
}
|
|
|
|
tempSlider.oninput = sendSensorUpdate;
|
|
humSlider.oninput = sendSensorUpdate;
|
|
lightSlider.oninput = sendSensorUpdate;
|
|
|
|
// LED Animations
|
|
let ledInterval = null;
|
|
|
|
function updateLedRing(r, g, b, mode) {
|
|
clearInterval(ledInterval);
|
|
document.getElementById('ledModeText').textContent = `Mode: ${mode.toUpperCase()} (${r}, ${g}, ${b})`;
|
|
const colorStr = `rgb(${r}, ${g}, ${b})`;
|
|
|
|
ledPixels.forEach(p => {
|
|
p.style.animation = 'none';
|
|
p.style.backgroundColor = 'rgba(51, 65, 85, 0.4)';
|
|
p.style.boxShadow = 'inset 0 2px 4px rgba(0,0,0,0.6)';
|
|
});
|
|
|
|
if (mode === 'static') {
|
|
ledPixels.forEach(p => {
|
|
p.style.backgroundColor = colorStr;
|
|
p.style.boxShadow = `0 0 15px rgba(${r}, ${g}, ${b}, 0.8), inset 0 1px 2px rgba(255,255,255,0.4)`;
|
|
});
|
|
}
|
|
else if (mode === 'breath') {
|
|
ledPixels.forEach(p => {
|
|
p.style.setProperty('--led-color', colorStr);
|
|
p.style.backgroundColor = colorStr;
|
|
p.style.boxShadow = `0 0 15px rgba(${r}, ${g}, ${b}, 0.8)`;
|
|
p.style.animation = 'led-breath 2s infinite ease-in-out';
|
|
});
|
|
}
|
|
else if (mode === 'rainbow') {
|
|
let hue = 0;
|
|
ledInterval = setInterval(() => {
|
|
ledPixels.forEach((p, idx) => {
|
|
const localHue = (hue + (idx * 360 / ledCount)) % 360;
|
|
const c = `hsl(${localHue}, 100%, 50%)`;
|
|
p.style.backgroundColor = c;
|
|
p.style.boxShadow = `0 0 12px ${c}`;
|
|
});
|
|
hue = (hue + 5) % 360;
|
|
}, 50);
|
|
}
|
|
else if (mode === 'off') {
|
|
// Done above
|
|
}
|
|
}
|
|
|
|
// Web Audio Synthesizer
|
|
function playToneLocal(frequency, durationMs, volume) {
|
|
try {
|
|
const actx = getAudioContext();
|
|
const osc = actx.createOscillator();
|
|
const gain = actx.createGain();
|
|
|
|
osc.connect(gain);
|
|
gain.connect(actx.destination);
|
|
|
|
osc.type = 'sine';
|
|
osc.frequency.setValueAtTime(frequency, actx.currentTime);
|
|
|
|
// Volume ramp
|
|
gain.gain.setValueAtTime(volume, actx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + (durationMs / 1000.0));
|
|
|
|
osc.start();
|
|
osc.stop(actx.currentTime + (durationMs / 1000.0));
|
|
|
|
triggerVisualizer(durationMs);
|
|
} catch (err) {
|
|
console.error("Failed to play synthesis tone:", err);
|
|
}
|
|
}
|
|
|
|
// Play WAV/MP3 Audio file from base64
|
|
function playAudioLocal(base64Data, volume) {
|
|
try {
|
|
const actx = getAudioContext();
|
|
|
|
// Decode base64
|
|
const binaryString = atob(base64Data.startsWith('data:') ? base64Data.split(',')[1] : base64Data);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
|
|
actx.decodeAudioData(bytes.buffer, function(buffer) {
|
|
const source = actx.createBufferSource();
|
|
const gain = actx.createGain();
|
|
|
|
source.buffer = buffer;
|
|
gain.gain.value = volume;
|
|
|
|
source.connect(gain);
|
|
gain.connect(actx.destination);
|
|
|
|
source.start(0);
|
|
triggerVisualizer(buffer.duration * 1000);
|
|
}, function(err) {
|
|
console.error("decodeAudioData error:", err);
|
|
});
|
|
} catch (err) {
|
|
console.error("Audio playback failed:", err);
|
|
}
|
|
}
|
|
|
|
// Audio Voice Recording Handler
|
|
let recordingTimer = null;
|
|
const micButton = document.getElementById('micButton');
|
|
const recordingStatus = document.getElementById('recordingStatus');
|
|
|
|
async function startVoiceRecording(durationSec, recordingId) {
|
|
const actx = getAudioContext();
|
|
|
|
recordingStatus.textContent = `Recording... (${durationSec}s)`;
|
|
recordingStatus.classList.add('active');
|
|
micButton.classList.add('recording');
|
|
|
|
// Trigger visualizer while recording
|
|
triggerVisualizer(durationSec * 1000);
|
|
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const source = actx.createMediaStreamSource(stream);
|
|
const processor = actx.createScriptProcessor(4096, 1, 1);
|
|
|
|
const leftChannel = [];
|
|
let isRecording = true;
|
|
|
|
processor.onaudioprocess = function(e) {
|
|
if (!isRecording) return;
|
|
const samples = e.inputBuffer.getChannelData(0);
|
|
leftChannel.push(new Float32Array(samples));
|
|
};
|
|
|
|
source.connect(processor);
|
|
processor.connect(actx.destination);
|
|
|
|
// Stop recording after duration
|
|
setTimeout(() => {
|
|
isRecording = false;
|
|
|
|
// Stop tracks
|
|
stream.getTracks().forEach(track => track.stop());
|
|
processor.disconnect();
|
|
source.disconnect();
|
|
|
|
// Process and compile WAV file
|
|
const completedBuffer = mergeBuffers(leftChannel);
|
|
const wavBlob = encodeWavMono(completedBuffer, actx.sampleRate);
|
|
|
|
// Convert to base64
|
|
const reader = new FileReader();
|
|
reader.onloadend = function() {
|
|
const b64Wav = reader.result.split(',')[1];
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({
|
|
event: "voice_recording",
|
|
recording_id: recordingId,
|
|
audio_base64: b64Wav
|
|
}));
|
|
}
|
|
|
|
// Done status
|
|
recordingStatus.textContent = "Voice Sent successfully!";
|
|
recordingStatus.classList.remove('active');
|
|
micButton.classList.remove('recording');
|
|
setTimeout(() => {
|
|
recordingStatus.textContent = "Microphone Idle";
|
|
}, 2000);
|
|
};
|
|
reader.readAsDataURL(wavBlob);
|
|
}, durationSec * 1000);
|
|
|
|
} catch (err) {
|
|
console.error("Recording initialization failed:", err);
|
|
recordingStatus.textContent = "Mic Permission Denied";
|
|
recordingStatus.classList.remove('active');
|
|
micButton.classList.remove('recording');
|
|
|
|
// Return error response back to server so it doesn't hang
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({
|
|
event: "voice_recording",
|
|
recording_id: recordingId,
|
|
error: "Permission denied or failed to access mic: " + err.message
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper buffer merger for recording
|
|
function mergeBuffers(channelBuffer) {
|
|
let resultLength = 0;
|
|
for (let i = 0; i < channelBuffer.length; i++) {
|
|
resultLength += channelBuffer[i].length;
|
|
}
|
|
const result = new Float32Array(resultLength);
|
|
let offset = 0;
|
|
for (let i = 0; i < channelBuffer.length; i++) {
|
|
result.set(channelBuffer[i], offset);
|
|
offset += channelBuffer[i].length;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// WAV mono Encoder Helper
|
|
function encodeWavMono(samples, sampleRate) {
|
|
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
|
const view = new DataView(buffer);
|
|
|
|
/* RIFF identifier */
|
|
writeString(view, 0, 'RIFF');
|
|
/* file length */
|
|
view.setUint32(4, 36 + samples.length * 2, true);
|
|
/* RIFF type */
|
|
writeString(view, 8, 'WAVE');
|
|
/* format chunk identifier */
|
|
writeString(view, 12, 'fmt ');
|
|
/* format chunk length */
|
|
view.setUint32(16, 16, true);
|
|
/* sample format (raw PCM) */
|
|
view.setUint16(20, 1, true);
|
|
/* channel count */
|
|
view.setUint16(22, 1, true);
|
|
/* sample rate */
|
|
view.setUint32(24, sampleRate, true);
|
|
/* byte rate (sample rate * block align) */
|
|
view.setUint32(28, sampleRate * 2, true);
|
|
/* block align (channel count * bytes per sample) */
|
|
view.setUint16(32, 2, true);
|
|
/* bits per sample */
|
|
view.setUint16(34, 16, true);
|
|
/* data chunk identifier */
|
|
writeString(view, 36, 'data');
|
|
/* data chunk length */
|
|
view.setUint32(40, samples.length * 2, true);
|
|
|
|
// Write sample volume values
|
|
floatTo16BitPCM(view, 44, samples);
|
|
|
|
return new Blob([view], { type: 'audio/wav' });
|
|
}
|
|
|
|
function floatTo16BitPCM(output, offset, input) {
|
|
for (let i = 0; i < input.length; i++, offset += 2) {
|
|
let s = Math.max(-1, Math.min(1, input[i]));
|
|
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
|
}
|
|
}
|
|
|
|
function writeString(view, offset, string) {
|
|
for (let i = 0; i < string.length; i++) {
|
|
view.setUint8(offset + i, string.charCodeAt(i));
|
|
}
|
|
}
|
|
|
|
// Latency Measurement Ping-Pong
|
|
setInterval(() => {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
const startTime = Date.now();
|
|
socket.send(JSON.stringify({ event: "ping", t: startTime }));
|
|
}
|
|
}, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|