initial
This commit is contained in:
600
examples/client.html
Normal file
600
examples/client.html
Normal file
@@ -0,0 +1,600 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>wl-webrtc - Remote Desktop Client</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #2d2d2d;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #404040;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.disconnected {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-badge.connecting {
|
||||
background: #ea580c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-badge.connected {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
background: #0a0a0a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#remoteVideo {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
background: #2d2d2d;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.config-form h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-message.visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>wl-webrtc</h1>
|
||||
<div class="status">
|
||||
<span id="connectionStatus" class="status-badge disconnected">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div id="errorMessage" class="error-message"></div>
|
||||
|
||||
<div id="configForm" class="config-form">
|
||||
<h2>Connect to Server</h2>
|
||||
<div class="form-group">
|
||||
<label for="serverUrl">Server URL</label>
|
||||
<input type="text" id="serverUrl" value="ws://localhost:8443" placeholder="ws://localhost:8443">
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="connectBtn" class="btn-primary">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="videoContainer" class="video-container hidden">
|
||||
<video id="remoteVideo" autoplay playsinline></video>
|
||||
</div>
|
||||
|
||||
<div id="connectionInfo" class="connection-info hidden">
|
||||
<div class="info-item">
|
||||
<div class="label">Bitrate</div>
|
||||
<div id="bitrate" class="value">0 Kbps</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">Resolution</div>
|
||||
<div id="resolution" class="value">0x0</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">FPS</div>
|
||||
<div id="fps" class="value">0</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">Latency</div>
|
||||
<div id="latency" class="value">0 ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="controls" class="controls hidden">
|
||||
<button id="disconnectBtn" class="btn-danger">Disconnect</button>
|
||||
<button id="fullscreenBtn" class="btn-secondary">Fullscreen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// State
|
||||
let peerConnection = null;
|
||||
let dataChannel = null;
|
||||
let ws = null;
|
||||
let statsInterval = null;
|
||||
let frameCount = 0;
|
||||
let lastFrameTime = Date.now();
|
||||
let rttInterval = null;
|
||||
let rttSamples = [];
|
||||
|
||||
// DOM Elements
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||
const fullscreenBtn = document.getElementById('fullscreenBtn');
|
||||
const serverUrlInput = document.getElementById('serverUrl');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const remoteVideo = document.getElementById('remoteVideo');
|
||||
const configForm = document.getElementById('configForm');
|
||||
const videoContainer = document.getElementById('videoContainer');
|
||||
const connectionInfo = document.getElementById('connectionInfo');
|
||||
const controls = document.getElementById('controls');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
|
||||
// Stats Elements
|
||||
const bitrateEl = document.getElementById('bitrate');
|
||||
const resolutionEl = document.getElementById('resolution');
|
||||
const fpsEl = document.getElementById('fps');
|
||||
const latencyEl = document.getElementById('latency');
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
// Update connection status
|
||||
function updateStatus(status) {
|
||||
connectionStatus.className = `status-badge ${status}`;
|
||||
connectionStatus.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
errorMessage.textContent = message;
|
||||
errorMessage.classList.add('visible');
|
||||
setTimeout(() => {
|
||||
errorMessage.classList.remove('visible');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Create WebRTC peer connection
|
||||
function createPeerConnection() {
|
||||
peerConnection = new RTCPeerConnection(config);
|
||||
|
||||
// Handle ICE candidates
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
sendJson({
|
||||
type: 'ice-candidate',
|
||||
candidate: event.candidate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connection state changes
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log('Connection state:', peerConnection.connectionState);
|
||||
switch (peerConnection.connectionState) {
|
||||
case 'connected':
|
||||
updateStatus('connected');
|
||||
startStatsMonitoring();
|
||||
break;
|
||||
case 'disconnected':
|
||||
updateStatus('connecting');
|
||||
break;
|
||||
case 'failed':
|
||||
updateStatus('disconnected');
|
||||
showError('Connection failed');
|
||||
cleanup();
|
||||
break;
|
||||
case 'closed':
|
||||
updateStatus('disconnected');
|
||||
cleanup();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle incoming tracks
|
||||
peerConnection.ontrack = (event) => {
|
||||
console.log('Received track:', event.track.kind);
|
||||
if (event.track.kind === 'video') {
|
||||
remoteVideo.srcObject = event.streams[0];
|
||||
videoContainer.classList.remove('hidden');
|
||||
connectionInfo.classList.remove('hidden');
|
||||
controls.classList.remove('hidden');
|
||||
configForm.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// Create data channel for control messages
|
||||
dataChannel = peerConnection.createDataChannel('control');
|
||||
dataChannel.onopen = () => {
|
||||
console.log('Data channel opened');
|
||||
};
|
||||
dataChannel.onmessage = (event) => {
|
||||
console.log('Data channel message:', event.data);
|
||||
};
|
||||
|
||||
return peerConnection;
|
||||
}
|
||||
|
||||
// Send JSON over WebSocket
|
||||
function sendJson(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to server
|
||||
async function connect() {
|
||||
const url = serverUrlInput.value;
|
||||
console.log('Connecting to:', url);
|
||||
|
||||
updateStatus('connecting');
|
||||
connectBtn.disabled = true;
|
||||
|
||||
try {
|
||||
// Create WebSocket connection
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = async () => {
|
||||
console.log('WebSocket connected');
|
||||
|
||||
// Create peer connection
|
||||
createPeerConnection();
|
||||
|
||||
// Create offer
|
||||
const offer = await peerConnection.createOffer();
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
|
||||
// Send offer to server
|
||||
sendJson({
|
||||
type: 'offer',
|
||||
sdp: offer
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('Received:', message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'answer':
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
showError(message.error);
|
||||
updateStatus('disconnected');
|
||||
cleanup();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
showError('WebSocket error. Check server is running.');
|
||||
updateStatus('disconnected');
|
||||
connectBtn.disabled = false;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket closed');
|
||||
if (connectionStatus.classList.contains('connecting')) {
|
||||
updateStatus('disconnected');
|
||||
showError('Server closed the connection');
|
||||
}
|
||||
connectBtn.disabled = false;
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
showError('Failed to connect: ' + error.message);
|
||||
updateStatus('disconnected');
|
||||
connectBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from server
|
||||
function disconnect() {
|
||||
console.log('Disconnecting...');
|
||||
cleanup();
|
||||
configForm.classList.remove('hidden');
|
||||
videoContainer.classList.add('hidden');
|
||||
connectionInfo.classList.add('hidden');
|
||||
controls.classList.add('hidden');
|
||||
remoteVideo.srcObject = null;
|
||||
connectBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Cleanup resources
|
||||
function cleanup() {
|
||||
if (statsInterval) {
|
||||
clearInterval(statsInterval);
|
||||
statsInterval = null;
|
||||
}
|
||||
if (rttInterval) {
|
||||
clearInterval(rttInterval);
|
||||
rttInterval = null;
|
||||
}
|
||||
if (dataChannel) {
|
||||
dataChannel.close();
|
||||
dataChannel = null;
|
||||
}
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
rttSamples = [];
|
||||
}
|
||||
|
||||
// Start statistics monitoring
|
||||
function startStatsMonitoring() {
|
||||
statsInterval = setInterval(async () => {
|
||||
if (!peerConnection || peerConnection.connectionState !== 'connected') {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await peerConnection.getStats();
|
||||
let bitrate = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
|
||||
// Calculate bitrate
|
||||
if (report.bytesReceived && report.bytesReceivedLast) {
|
||||
const bytesPerSec = report.bytesReceived - report.bytesReceivedLast;
|
||||
bitrate = (bytesPerSec * 8) / 1000; // kbps
|
||||
}
|
||||
report.bytesReceivedLast = report.bytesReceived;
|
||||
|
||||
// Resolution
|
||||
width = report.frameWidth || 0;
|
||||
height = report.frameHeight || 0;
|
||||
|
||||
// FPS
|
||||
if (report.framesReceived && report.framesReceivedLast) {
|
||||
const framesPerSec = report.framesReceived - report.framesReceivedLast;
|
||||
fpsEl.textContent = framesPerSec.toFixed(1);
|
||||
}
|
||||
report.framesReceivedLast = report.framesReceived;
|
||||
}
|
||||
|
||||
if (report.type === 'remote-candidate' && report.currentRoundTripTime) {
|
||||
rttSamples.push(report.currentRoundTripTime * 1000); // Convert to ms
|
||||
if (rttSamples.length > 10) rttSamples.shift();
|
||||
const avgRtt = rttSamples.reduce((a, b) => a + b, 0) / rttSamples.length;
|
||||
latencyEl.textContent = avgRtt.toFixed(0) + ' ms';
|
||||
}
|
||||
});
|
||||
|
||||
bitrateEl.textContent = bitrate.toFixed(0) + ' Kbps';
|
||||
resolutionEl.textContent = `${width}x${height}`;
|
||||
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Toggle fullscreen
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
remoteVideo.requestFullscreen().catch(err => {
|
||||
showError(`Fullscreen error: ${err.message}`);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
connectBtn.addEventListener('click', connect);
|
||||
disconnectBtn.addEventListener('click', disconnect);
|
||||
fullscreenBtn.addEventListener('click', toggleFullscreen);
|
||||
|
||||
// Handle window close
|
||||
window.addEventListener('beforeunload', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'f' && event.altKey) {
|
||||
toggleFullscreen();
|
||||
}
|
||||
if (event.key === 'Escape' && document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
// Update video stats on frame
|
||||
remoteVideo.addEventListener('play', () => {
|
||||
frameCount = 0;
|
||||
lastFrameTime = Date.now();
|
||||
});
|
||||
|
||||
remoteVideo.addEventListener('timeupdate', () => {
|
||||
frameCount++;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user