This commit is contained in:
2026-02-03 11:14:25 +08:00
commit 8d6a720e8d
26 changed files with 35602 additions and 0 deletions

600
examples/client.html Normal file
View 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>