utility-scripts/lanshare/static/index.html
Claude c617a371cf
Fix iOS WebSocket hang with self-signed TLS certificates
iOS Safari silently rejects wss:// connections to servers using
self-signed certificates — the trust exception accepted for the page
does not extend to WebSocket handshakes. This causes the UI to hang
on "Establishing WebSocket connection..." indefinitely.

- Make --certificate and --key optional; server runs plain HTTP when omitted
- When TLS is enabled, also start a plain HTTP listener on --http-port
  (default 8081) so iOS clients can connect via ws:// instead of wss://
- Add a 5-second connection timeout on the frontend that detects the hang
  and shows a clickable link to the HTTP fallback URL

https://claude.ai/code/session_01VJ4CsBALnYcVhFJpY5cD5k
2026-02-09 09:42:51 +00:00

1236 lines
52 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LAN Share</title>
<script src="https://cdn.tailwindcss.com"></script>
<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=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
surface: {
900: '#0a0a0b',
800: '#111113',
700: '#1a1a1d',
600: '#242428',
500: '#2e2e33',
},
amber: {
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
},
slate: {
400: '#94a3b8',
500: '#64748b',
600: '#475569',
}
},
fontFamily: {
mono: ['JetBrains Mono', 'monospace'],
}
}
}
}
</script>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background-color: #0a0a0b;
}
.app-container {
display: grid;
grid-template-columns: 1fr 280px;
grid-template-rows: 1fr;
height: 100vh;
gap: 0;
}
.app-container.sidebar-collapsed {
grid-template-columns: 1fr 48px;
}
.app-container.sidebar-collapsed .sidebar-content {
display: none;
}
.app-container.sidebar-collapsed .sidebar-collapsed-strip {
display: flex;
}
.sidebar-collapsed-strip {
display: none;
}
.grid-pattern {
background-image:
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
background-size: 20px 20px;
}
.video-feed {
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
}
.video-container { cursor: pointer; }
.pulse-dot {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.thumbnail-card {
transition: all 0.15s ease;
}
.thumbnail-card:hover { background: #1a1a1d; }
.thumbnail-card.selected {
background: #1a1a1d;
box-shadow: inset 0 0 0 2px #f59e0b;
}
.main-view-container {
position: relative;
background: #000;
}
.main-video-wrapper {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.main-video-wrapper video,
.main-video-wrapper .video-placeholder {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
}
.floating-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
padding: 2rem 1rem 1rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.main-view-container:hover .floating-controls {
opacity: 1;
}
.source-indicator {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
}
.scrollbar-thin::-webkit-scrollbar { width: 4px; }
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #2e2e33;
border-radius: 2px;
}
/* Volume slider styling */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
height: 20px;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
background: linear-gradient(to right, #f59e0b var(--volume-percent, 80%), #52525b var(--volume-percent, 80%));
border-radius: 3px;
border: 1px solid #71717a;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3);
}
input[type="range"]::-moz-range-track {
height: 6px;
background: #52525b;
border-radius: 3px;
border: 1px solid #71717a;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3);
}
input[type="range"]::-moz-range-progress {
height: 6px;
background: #f59e0b;
border-radius: 3px 0 0 3px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
margin-top: -5px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
border: 2px solid #f59e0b;
}
input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
border: 2px solid #f59e0b;
}
input[type="range"]:hover::-webkit-slider-thumb {
background: #fef3c7;
}
input[type="range"]:hover::-moz-range-thumb {
background: #fef3c7;
}
.connecting-overlay {
position: fixed;
inset: 0;
background: rgba(10, 10, 11, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #242428;
border-top-color: #f59e0b;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body class="font-mono text-slate-400 antialiased">
<!-- Connecting overlay -->
<div id="connectingOverlay" class="connecting-overlay">
<div class="text-center">
<div class="spinner mx-auto mb-4"></div>
<div class="text-white font-medium">Connecting to server...</div>
<div class="text-slate-500 text-sm mt-1" id="connectionStatus">Establishing WebSocket connection</div>
</div>
</div>
<div class="app-container" id="appContainer">
<!-- Main View Area -->
<main class="main-view-container">
<div class="source-indicator flex items-center gap-2 px-3 py-1.5 rounded-lg bg-surface-900/90 border border-surface-600 backdrop-blur-sm" id="sourceIndicator" style="display: none;">
<div class="w-2 h-2 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-sm text-white font-medium" id="mainSourceName">-</span>
<span class="text-xs text-slate-500" id="mainSourceType"></span>
</div>
<div class="main-video-wrapper">
<video id="mainVideo" class="video-feed" autoplay playsinline></video>
<div id="mainPlaceholder" class="video-placeholder grid-pattern flex items-center justify-center bg-surface-900">
<div class="text-center">
<svg class="w-20 h-20 text-slate-800 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<p class="text-slate-600" id="placeholderText">Waiting for someone to share their screen...</p>
</div>
</div>
</div>
<div class="floating-controls">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<button id="muteBtn" class="p-2 rounded-lg bg-surface-800/80 border border-surface-600 text-white hover:bg-surface-700 transition-colors" title="Toggle audio (M)">
<svg id="volumeIcon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/>
</svg>
<svg id="mutedIcon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"/>
</svg>
</button>
<input type="range" id="volumeSlider" min="0" max="100" value="80" class="w-24">
</div>
<button id="fullscreenBtn" class="p-2 rounded-lg bg-surface-800/80 border border-surface-600 text-white hover:bg-surface-700 transition-colors" title="Fullscreen (F)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
</svg>
</button>
</div>
<div class="text-xs text-slate-500" id="mainSourceMeta"></div>
</div>
</div>
</main>
<!-- Sidebar -->
<aside class="bg-surface-900 border-l border-surface-700 flex flex-col h-screen overflow-hidden">
<div class="sidebar-collapsed-strip flex-col items-center py-3 h-full">
<button onclick="toggleSidebar()" class="p-2 rounded hover:bg-surface-700 text-slate-500 hover:text-white transition-colors mb-4" title="Expand sidebar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/>
</svg>
</button>
<div class="w-6 h-6 rounded bg-amber-500/10 border border-amber-500/30 flex items-center justify-center">
<svg class="w-3 h-3 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
</div>
<div class="sidebar-content flex flex-col h-full">
<header class="p-3 border-b border-surface-700 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded bg-amber-500/10 border border-amber-500/30 flex items-center justify-center">
<svg class="w-3 h-3 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<span class="text-sm font-semibold text-white">LAN Share</span>
</div>
<button onclick="toggleSidebar()" class="p-1 rounded hover:bg-surface-700 text-slate-500 hover:text-white transition-colors" title="Collapse sidebar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"/>
</svg>
</button>
</header>
<!-- Your Sharing Controls -->
<section class="p-3 border-b border-surface-700 shrink-0">
<div class="text-xs text-slate-600 uppercase tracking-wider mb-2">Your Share</div>
<!-- Not sharing state -->
<button id="startShareBtn" class="w-full px-3 py-2.5 rounded-lg bg-surface-700 border border-surface-600 hover:border-slate-500 transition-all mb-3">
<div class="flex items-center gap-2">
<div class="w-2.5 h-2.5 rounded-full bg-slate-600"></div>
<span class="text-slate-400 font-medium text-sm">Start Sharing</span>
</div>
</button>
<!-- Sharing state (hidden by default) -->
<button id="stopShareBtn" class="w-full px-3 py-2.5 rounded-lg bg-amber-500/10 border border-amber-500/30 hover:bg-amber-500/20 transition-all mb-3 hidden">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-2.5 h-2.5 rounded-full bg-amber-400 animate-pulse"></div>
<span class="text-amber-400 font-medium text-sm">Sharing</span>
</div>
<span class="text-xs text-amber-500/70">Stop</span>
</div>
</button>
<div class="flex gap-2">
<button id="shareScreenBtn" class="flex-1 px-2 py-1.5 rounded bg-surface-700 border border-surface-600 hover:border-slate-500 transition-colors text-xs text-slate-400 hover:text-white">
Screen
</button>
<button id="shareWindowBtn" class="flex-1 px-2 py-1.5 rounded bg-surface-800 border border-surface-700 hover:border-slate-500 transition-colors text-xs text-slate-500 hover:text-white">
Window
</button>
<button id="audioToggle" class="p-1.5 rounded bg-emerald-500/10 border border-emerald-500/30 hover:bg-emerald-500/20 transition-colors text-emerald-400" title="Include audio">
<svg class="w-4 h-4" fill="currentColor" 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 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
</button>
</div>
<div id="audioStatus" class="mt-2 flex items-center gap-2 text-xs text-emerald-400">
<svg class="w-3 h-3" fill="currentColor" 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 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span>Audio will be included</span>
</div>
</section>
<!-- Available Screens -->
<section class="flex-1 overflow-hidden flex flex-col min-h-0">
<div class="p-3 pb-2 shrink-0">
<div class="flex items-center justify-between">
<span class="text-xs text-slate-600 uppercase tracking-wider">Screens</span>
<span class="text-xs text-slate-600" id="screenCount">0</span>
</div>
</div>
<div class="flex-1 overflow-y-auto scrollbar-thin px-3 pb-3 space-y-2" id="screenList">
<!-- Screen thumbnails will be added here dynamically -->
<div id="noScreensMessage" class="text-center py-8 text-slate-600 text-xs">
No one is sharing yet
</div>
</div>
</section>
<!-- Connection Status Footer -->
<footer class="p-3 border-t border-surface-700 shrink-0">
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-2">
<div id="connectionDot" class="w-2 h-2 rounded-full bg-emerald-400 pulse-dot"></div>
<span class="text-slate-500" id="connectionLabel">Connected</span>
</div>
<span class="text-slate-600 font-mono">:8080</span>
</div>
<div class="mt-1 text-[10px] text-slate-700">
<span id="peerCount">0</span> peers • WebRTC P2P
</div>
</footer>
</div>
</aside>
</div>
<script>
// ==================== State ====================
const state = {
peerId: null,
peerIp: null, // Our own IP as seen by the server
peerName: localStorage.getItem('lanshare_name') || null,
peers: new Map(), // peer_id -> { name, ip, isSharing, hasAudio, stream, outgoingPC, incomingPC }
localStream: null,
isSharing: false,
includeAudio: true,
selectedPeerId: null,
ws: null,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
};
// WebRTC config - no STUN/TURN needed for LAN-only connections
// WebRTC will use "host" ICE candidates (local IPs) which work directly on LAN
// WebRTC config - LAN only, no STUN/TURN servers needed
const rtcConfig = {
iceServers: [],
};
// Toggle this to test bundled vs trickle ICE
const USE_TRICKLE_ICE = true; // Set to false to test bundled approach
// Debug: Parse and log SDP candidates
function extractCandidatesFromSDP(sdp) {
const lines = sdp.split('\r\n');
const candidates = lines.filter(line => line.startsWith('a=candidate:'));
return candidates;
}
// Replace mDNS .local addresses with a real IP.
// Modern browsers obfuscate local IPs as UUIDs ending in .local for privacy,
// which breaks direct LAN connections when mDNS resolution isn't available.
function replaceMdnsWithIp(str, realIp) {
if (!str || !realIp) return str;
return str.replace(/[a-f0-9-]+\.local/gi, realIp);
}
console.log(`ICE mode: ${USE_TRICKLE_ICE ? 'TRICKLE' : 'BUNDLED'}`);
// ==================== DOM Elements ====================
const $ = id => document.getElementById(id);
const connectingOverlay = $('connectingOverlay');
const connectionStatus = $('connectionStatus');
const mainVideo = $('mainVideo');
const mainPlaceholder = $('mainPlaceholder');
const placeholderText = $('placeholderText');
const sourceIndicator = $('sourceIndicator');
const mainSourceName = $('mainSourceName');
const mainSourceType = $('mainSourceType');
const mainSourceMeta = $('mainSourceMeta');
const startShareBtn = $('startShareBtn');
const stopShareBtn = $('stopShareBtn');
const shareScreenBtn = $('shareScreenBtn');
const shareWindowBtn = $('shareWindowBtn');
const audioToggle = $('audioToggle');
const audioStatus = $('audioStatus');
const screenList = $('screenList');
const screenCount = $('screenCount');
const noScreensMessage = $('noScreensMessage');
const peerCount = $('peerCount');
const connectionDot = $('connectionDot');
const connectionLabel = $('connectionLabel');
const volumeSlider = $('volumeSlider');
const muteBtn = $('muteBtn');
const volumeIcon = $('volumeIcon');
const mutedIcon = $('mutedIcon');
const fullscreenBtn = $('fullscreenBtn');
// ==================== WebSocket ====================
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
connectionStatus.textContent = 'Establishing WebSocket connection...';
state.ws = new WebSocket(wsUrl);
// iOS Safari silently rejects wss:// connections to servers with
// self-signed certificates — onopen never fires and the UI hangs.
// Use a timeout to detect this and show a helpful message.
const connectTimeout = setTimeout(() => {
if (state.ws && state.ws.readyState !== WebSocket.OPEN) {
console.warn('WebSocket connection timed out');
state.ws.close();
if (window.location.protocol === 'https:') {
const httpPort = parseInt(window.location.port || '443', 10) + 1;
const httpUrl = `http://${window.location.hostname}:${httpPort}`;
connectionStatus.innerHTML =
'Connection failed — iOS may not support self-signed WSS certificates.<br>' +
'Try the plain HTTP URL instead: <a href="' + httpUrl + '" style="color:#60a5fa;text-decoration:underline">' + httpUrl + '</a>';
}
}
}, 5000);
state.ws.onopen = () => {
clearTimeout(connectTimeout);
console.log('WebSocket connected');
state.reconnectAttempts = 0;
connectionStatus.textContent = 'Connected, waiting for welcome...';
};
state.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleSignal(msg);
};
state.ws.onclose = () => {
clearTimeout(connectTimeout);
console.log('WebSocket disconnected');
updateConnectionStatus(false);
if (state.reconnectAttempts < state.maxReconnectAttempts) {
state.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 30000);
connectionStatus.textContent = `Reconnecting in ${delay/1000}s...`;
connectingOverlay.style.display = 'flex';
setTimeout(connect, delay);
}
};
state.ws.onerror = (err) => {
clearTimeout(connectTimeout);
console.error('WebSocket error:', err);
};
}
function send(msg) {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify(msg));
}
}
// ==================== Signal Handling ====================
async function handleSignal(msg) {
console.log('Received:', msg.type, msg);
switch (msg.type) {
case 'welcome':
state.peerId = msg.peer_id;
state.peerIp = msg.ip;
console.log(`Our IP as seen by server: ${msg.ip}`);
connectingOverlay.style.display = 'none';
updateConnectionStatus(true);
// Set name
if (!state.peerName) {
state.peerName = prompt('Enter your name:', `User ${msg.peer_id.slice(0, 4)}`) || `User ${msg.peer_id.slice(0, 4)}`;
localStorage.setItem('lanshare_name', state.peerName);
}
send({ type: 'announce', name: state.peerName });
// Add existing peers
for (const peer of msg.peers) {
addPeer(peer.peer_id, peer.name, peer.ip, peer.is_sharing, peer.has_audio);
// Request stream from peers that are already sharing
if (peer.is_sharing) {
requestStreamFrom(peer.peer_id);
}
}
updateUI();
break;
case 'peer_joined':
addPeer(msg.peer_id, msg.name, msg.ip, false, false);
// If we're sharing, proactively send an offer to the new peer
if (state.isSharing && state.localStream) {
await sendOfferTo(msg.peer_id);
}
updateUI();
break;
case 'peer_left':
removePeer(msg.peer_id);
updateUI();
break;
case 'started_sharing':
if (state.peers.has(msg.peer_id)) {
const peer = state.peers.get(msg.peer_id);
peer.isSharing = true;
peer.hasAudio = msg.has_audio;
// Request stream from the peer that just started sharing
requestStreamFrom(msg.peer_id);
}
updateUI();
break;
case 'stopped_sharing':
if (state.peers.has(msg.peer_id)) {
const peer = state.peers.get(msg.peer_id);
peer.isSharing = false;
peer.hasAudio = false;
peer.stream = null;
// Only close the incoming connection (their stream to us).
// Keep our outgoing connection if we're sharing to them.
if (peer.incomingPC) {
peer.incomingPC.close();
peer.incomingPC = null;
}
if (state.selectedPeerId === msg.peer_id) {
state.selectedPeerId = null;
mainVideo.srcObject = null;
}
}
updateUI();
break;
case 'request_stream':
// Someone is requesting our stream
if (state.isSharing && state.localStream) {
// Ensure we have this peer in our state (handles race condition)
if (!state.peers.has(msg.from)) {
addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, null, false, false);
}
await sendOfferTo(msg.from);
}
break;
case 'offer':
await handleOffer(msg.from, msg.sdp);
break;
case 'answer':
await handleAnswer(msg.from, msg.sdp);
break;
case 'ice_candidate':
handleIceCandidate(msg.from, msg.candidate);
break;
}
}
// ==================== Peer Management ====================
function addPeer(peerId, name, ip, isSharing, hasAudio) {
if (peerId === state.peerId) return;
state.peers.set(peerId, {
name,
ip,
isSharing,
hasAudio,
stream: null,
outgoingPC: null, // PC where we send our stream to this peer
incomingPC: null, // PC where we receive this peer's stream
});
}
function removePeer(peerId) {
const peer = state.peers.get(peerId);
if (peer) {
if (peer.outgoingPC) peer.outgoingPC.close();
if (peer.incomingPC) peer.incomingPC.close();
state.peers.delete(peerId);
if (state.selectedPeerId === peerId) {
state.selectedPeerId = null;
mainVideo.srcObject = null;
}
}
}
// ==================== WebRTC ====================
// direction: 'outgoing' (we send offer) or 'incoming' (we receive offer)
function createPeerConnection(peerId, direction) {
const pc = new RTCPeerConnection(rtcConfig);
// Debug: Log ICE gathering state changes
pc.onicegatheringstatechange = () => {
console.log(`[${peerId.slice(0,8)}:${direction}] ICE gathering state: ${pc.iceGatheringState}`);
if (pc.iceGatheringState === 'complete') {
const sdp = pc.localDescription?.sdp || '';
const candidates = extractCandidatesFromSDP(sdp);
console.log(`[${peerId.slice(0,8)}:${direction}] Final SDP has ${candidates.length} candidates:`);
candidates.forEach(c => console.log(` ${c}`));
}
};
// Debug: Log ICE connection state
pc.oniceconnectionstatechange = () => {
console.log(`[${peerId.slice(0,8)}:${direction}] ICE connection state: ${pc.iceConnectionState}`);
};
// Trickle ICE - send candidates as they're discovered
pc.onicecandidate = (event) => {
if (event.candidate) {
const c = event.candidate;
console.log(`[${peerId.slice(0,8)}:${direction}] Local candidate: ${c.candidate}`);
console.log(` → type: ${c.type}, protocol: ${c.protocol}, address: ${c.address}, port: ${c.port}`);
if (USE_TRICKLE_ICE) {
// Replace mDNS .local addresses with our real IP for direct LAN connectivity
const candidateObj = event.candidate.toJSON();
candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp);
// Embed direction so the remote side routes to the right PC
send({
type: 'ice_candidate',
from: state.peerId,
to: peerId,
candidate: JSON.stringify({ ...candidateObj, _dir: direction }),
});
}
} else {
console.log(`[${peerId.slice(0,8)}:${direction}] ICE candidate gathering complete (null candidate)`);
}
};
pc.ontrack = (event) => {
console.log(`[${peerId.slice(0,8)}] Received track:`, event.track.kind);
const peer = state.peers.get(peerId);
if (peer) {
// Use the associated stream, or build one from the track
// (event.streams can be empty in some WebRTC edge cases)
if (event.streams[0]) {
peer.stream = event.streams[0];
} else if (!peer.stream) {
peer.stream = new MediaStream([event.track]);
} else {
peer.stream.addTrack(event.track);
}
updateUI();
// Auto-select if this is the first/only stream
if (!state.selectedPeerId) {
selectPeer(peerId);
}
}
};
pc.onconnectionstatechange = () => {
console.log(`[${peerId.slice(0,8)}:${direction}] Connection state: ${pc.connectionState}`);
};
return pc;
}
async function requestStreamFrom(peerId) {
send({
type: 'request_stream',
from: state.peerId,
to: peerId,
});
}
// Wait for ICE gathering to complete (for bundled mode)
function waitForIceGathering(pc, peerId) {
return new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') {
console.log(`[${peerId.slice(0,8)}] ICE already complete`);
resolve();
return;
}
const checkState = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', checkState);
console.log(`[${peerId.slice(0,8)}] ICE gathering finished`);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', checkState);
// Timeout after 2s
setTimeout(() => {
pc.removeEventListener('icegatheringstatechange', checkState);
console.log(`[${peerId.slice(0,8)}] ICE gathering timeout (state: ${pc.iceGatheringState})`);
resolve();
}, 2000);
});
}
async function sendOfferTo(peerId) {
const peer = state.peers.get(peerId);
if (!peer) {
console.warn('sendOfferTo: peer not found:', peerId);
return;
}
// Don't create a new outgoing connection if we already have one
if (peer.outgoingPC && peer.outgoingPC.connectionState !== 'closed') {
console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have outgoing connection`);
return;
}
console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(peerId, 'outgoing');
peer.outgoingPC = pc;
// Add local stream tracks
if (state.localStream) {
state.localStream.getTracks().forEach(track => {
pc.addTrack(track, state.localStream);
});
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!USE_TRICKLE_ICE) {
// Bundled mode: wait for all candidates to be in SDP
await waitForIceGathering(pc, peerId);
}
// Replace mDNS .local addresses with our real IP for direct LAN connectivity
const sdpToSend = { type: pc.localDescription.type, sdp: replaceMdnsWithIp(pc.localDescription.sdp, state.peerIp) };
const candidates = extractCandidatesFromSDP(sdpToSend.sdp);
console.log(`[${peerId.slice(0,8)}] Sending offer with ${candidates.length} candidates in SDP`);
send({
type: 'offer',
from: state.peerId,
to: peerId,
sdp: JSON.stringify(sdpToSend),
});
}
async function handleOffer(fromPeerId, sdpJson) {
// Ensure we have this peer in state
if (!state.peers.has(fromPeerId)) {
addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, null, true, false);
}
const peer = state.peers.get(fromPeerId);
// Close existing incoming connection if any (new offer = renegotiation)
if (peer.incomingPC) {
peer.incomingPC.close();
}
console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(fromPeerId, 'incoming');
peer.incomingPC = pc;
// Replace any mDNS .local addresses in the remote SDP with the peer's real IP
const sdp = JSON.parse(sdpJson);
sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip);
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Received offer with ${remoteCandidates.length} candidates in SDP:`);
remoteCandidates.forEach(c => console.log(` ${c}`));
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
if (!USE_TRICKLE_ICE) {
// Bundled mode: wait for all candidates to be in SDP
await waitForIceGathering(pc, fromPeerId);
}
// Replace mDNS .local addresses with our real IP for direct LAN connectivity
const sdpToSend = { type: pc.localDescription.type, sdp: replaceMdnsWithIp(pc.localDescription.sdp, state.peerIp) };
const localCandidates = extractCandidatesFromSDP(sdpToSend.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Sending answer with ${localCandidates.length} candidates in SDP`);
send({
type: 'answer',
from: state.peerId,
to: fromPeerId,
sdp: JSON.stringify(sdpToSend),
});
}
async function handleAnswer(fromPeerId, sdpJson) {
const peer = state.peers.get(fromPeerId);
if (!peer || !peer.outgoingPC) {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no outgoing peer connection`);
return;
}
// Only set remote description if we're in the right state
if (peer.outgoingPC.signalingState !== 'have-local-offer') {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.outgoingPC.signalingState}`);
return;
}
// Replace any mDNS .local addresses in the remote SDP with the peer's real IP
const sdp = JSON.parse(sdpJson);
sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip);
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Received answer with ${remoteCandidates.length} candidates in SDP:`);
remoteCandidates.forEach(c => console.log(` ${c}`));
console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`);
await peer.outgoingPC.setRemoteDescription(new RTCSessionDescription(sdp));
}
function handleIceCandidate(fromPeerId, candidateJson) {
const peer = state.peers.get(fromPeerId);
if (!peer) {
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: peer not found`);
return;
}
const data = JSON.parse(candidateJson);
const dir = data._dir;
delete data._dir;
// Their outgoing PC corresponds to our incoming PC (and vice versa)
const pc = dir === 'outgoing' ? peer.incomingPC : peer.outgoingPC;
if (!pc) {
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no matching PC for direction ${dir}`);
return;
}
// Replace any mDNS .local addresses with the peer's real IP
data.candidate = replaceMdnsWithIp(data.candidate, peer.ip);
console.log(`[${fromPeerId.slice(0,8)}:${dir}] Remote candidate: ${data.candidate}`);
pc.addIceCandidate(new RTCIceCandidate(data))
.then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`))
.catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err));
}
// ==================== Screen Sharing ====================
async function startSharing(displayMediaOptions) {
try {
const stream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
state.localStream = stream;
state.isSharing = true;
// Handle stream ending (user clicked "Stop sharing" in browser UI)
stream.getVideoTracks()[0].onended = () => {
stopSharing();
};
// Notify server
send({
type: 'started_sharing',
peer_id: state.peerId,
has_audio: stream.getAudioTracks().length > 0,
});
// Proactively send offers to all connected peers so they
// receive the stream immediately. Without this, we rely on
// a request_stream round-trip that can silently fail because
// handleSignal is async but never awaited by ws.onmessage.
for (const [peerId] of state.peers) {
sendOfferTo(peerId);
}
updateUI();
} catch (err) {
console.error('Error starting screen share:', err);
if (err.name !== 'NotAllowedError') {
alert('Failed to start screen sharing: ' + err.message);
}
}
}
function stopSharing() {
if (state.localStream) {
state.localStream.getTracks().forEach(track => track.stop());
state.localStream = null;
}
state.isSharing = false;
// Only close outgoing connections (where we were sending our stream).
// Keep incoming connections alive so we can still view others' streams.
for (const [peerId, peer] of state.peers) {
if (peer.outgoingPC) {
peer.outgoingPC.close();
peer.outgoingPC = null;
}
}
send({
type: 'stopped_sharing',
peer_id: state.peerId,
});
updateUI();
}
// ==================== UI Updates ====================
function selectPeer(peerId) {
const peer = state.peers.get(peerId);
if (!peer || !peer.stream) return;
state.selectedPeerId = peerId;
mainVideo.srcObject = peer.stream;
mainVideo.muted = false;
mainVideo.volume = volumeSlider.value / 100;
// Force play (needed for autoplay policies)
mainVideo.play().catch(() => {});
updateUI();
}
function updateUI() {
// Update peer count
peerCount.textContent = state.peers.size;
// Update sharing buttons (hide entirely if browser lacks getDisplayMedia)
const canShare = !!navigator.mediaDevices?.getDisplayMedia;
startShareBtn.classList.toggle('hidden', state.isSharing || !canShare);
stopShareBtn.classList.toggle('hidden', !state.isSharing);
// Update screen list
const sharingPeers = Array.from(state.peers.entries()).filter(([_, p]) => p.isSharing);
screenCount.textContent = sharingPeers.length;
noScreensMessage.style.display = sharingPeers.length === 0 ? 'block' : 'none';
// Clear and rebuild screen list
const existingCards = screenList.querySelectorAll('.thumbnail-card');
existingCards.forEach(card => card.remove());
const colors = ['emerald', 'blue', 'violet', 'pink', 'cyan', 'orange'];
let colorIndex = 0;
for (const [peerId, peer] of sharingPeers) {
const color = colors[colorIndex % colors.length];
colorIndex++;
const card = document.createElement('div');
card.className = `thumbnail-card rounded-lg p-2 video-container ${state.selectedPeerId === peerId ? 'selected' : ''}`;
card.onclick = () => selectPeer(peerId);
card.innerHTML = `
<div class="aspect-video rounded bg-surface-800 grid-pattern relative overflow-hidden mb-2">
<video class="video-feed w-full h-full object-cover" autoplay playsinline muted></video>
<div class="absolute top-1 left-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium">
<div class="w-1 h-1 rounded-full bg-white animate-pulse"></div>
LIVE
</div>
${peer.hasAudio ? `
<div class="absolute top-1 right-1 p-1 rounded bg-surface-900/80">
<svg class="w-3 h-3 text-emerald-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</div>
` : ''}
</div>
<div class="flex items-center gap-2">
<div class="w-5 h-5 rounded-full bg-${color}-500/20 border border-${color}-500/30 flex items-center justify-center">
<span class="text-${color}-400 text-[10px] font-semibold">${peer.name.charAt(0).toUpperCase()}</span>
</div>
<span class="text-xs ${state.selectedPeerId === peerId ? 'text-white font-medium' : 'text-slate-300'} truncate">${peer.name}</span>
</div>
`;
// Set thumbnail video
const thumbVideo = card.querySelector('video');
if (peer.stream) {
thumbVideo.srcObject = peer.stream;
thumbVideo.play().catch(() => {});
}
// Prevent video interaction
thumbVideo.style.pointerEvents = 'none';
thumbVideo.addEventListener('contextmenu', e => e.preventDefault());
thumbVideo.addEventListener('pause', () => thumbVideo.play().catch(() => {}));
screenList.appendChild(card);
}
// Update main view
if (state.selectedPeerId) {
const selectedPeer = state.peers.get(state.selectedPeerId);
if (selectedPeer && selectedPeer.stream) {
mainPlaceholder.style.display = 'none';
mainVideo.style.display = 'block';
sourceIndicator.style.display = 'flex';
mainSourceName.textContent = selectedPeer.name;
mainSourceType.textContent = selectedPeer.hasAudio ? '• Screen + Audio' : '• Screen';
mainSourceMeta.textContent = '';
} else {
showPlaceholder();
}
} else {
showPlaceholder();
}
}
function showPlaceholder() {
mainPlaceholder.style.display = 'flex';
mainVideo.style.display = 'none';
sourceIndicator.style.display = 'none';
const sharingCount = Array.from(state.peers.values()).filter(p => p.isSharing).length;
if (sharingCount > 0) {
placeholderText.textContent = 'Select a screen from the sidebar';
} else {
placeholderText.textContent = 'Waiting for someone to share their screen...';
}
}
function updateConnectionStatus(connected) {
connectionDot.className = `w-2 h-2 rounded-full ${connected ? 'bg-emerald-400 pulse-dot' : 'bg-red-400'}`;
connectionLabel.textContent = connected ? 'Connected' : 'Disconnected';
}
// ==================== Event Listeners ====================
function toggleSidebar() {
$('appContainer').classList.toggle('sidebar-collapsed');
}
startShareBtn.onclick = () => {
const options = {
video: { cursor: 'always' },
audio: state.includeAudio,
};
startSharing(options);
};
stopShareBtn.onclick = () => {
stopSharing();
};
shareScreenBtn.onclick = () => {
shareScreenBtn.className = 'flex-1 px-2 py-1.5 rounded bg-surface-700 border border-surface-600 hover:border-slate-500 transition-colors text-xs text-slate-400 hover:text-white';
shareWindowBtn.className = 'flex-1 px-2 py-1.5 rounded bg-surface-800 border border-surface-700 hover:border-slate-500 transition-colors text-xs text-slate-500 hover:text-white';
};
shareWindowBtn.onclick = () => {
shareWindowBtn.className = 'flex-1 px-2 py-1.5 rounded bg-surface-700 border border-surface-600 hover:border-slate-500 transition-colors text-xs text-slate-400 hover:text-white';
shareScreenBtn.className = 'flex-1 px-2 py-1.5 rounded bg-surface-800 border border-surface-700 hover:border-slate-500 transition-colors text-xs text-slate-500 hover:text-white';
};
audioToggle.onclick = () => {
state.includeAudio = !state.includeAudio;
if (state.includeAudio) {
audioToggle.className = 'p-1.5 rounded bg-emerald-500/10 border border-emerald-500/30 hover:bg-emerald-500/20 transition-colors text-emerald-400';
audioStatus.innerHTML = `
<svg class="w-3 h-3" fill="currentColor" 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 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span>Audio will be included</span>
`;
} else {
audioToggle.className = 'p-1.5 rounded bg-surface-700 border border-surface-600 hover:border-slate-500 transition-colors text-slate-500';
audioStatus.innerHTML = `
<svg class="w-3 h-3" fill="currentColor" 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 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span class="text-slate-500">Audio disabled</span>
`;
}
};
volumeSlider.oninput = () => {
mainVideo.volume = volumeSlider.value / 100;
mainVideo.muted = false;
updateMuteIcon();
updateVolumeSliderVisual();
};
function updateVolumeSliderVisual() {
volumeSlider.style.setProperty('--volume-percent', volumeSlider.value + '%');
}
// Initialize slider visual
updateVolumeSliderVisual();
muteBtn.onclick = () => {
mainVideo.muted = !mainVideo.muted;
updateMuteIcon();
};
function updateMuteIcon() {
const muted = mainVideo.muted;
volumeIcon.classList.toggle('hidden', muted);
mutedIcon.classList.toggle('hidden', !muted);
}
fullscreenBtn.onclick = () => {
const container = document.querySelector('.main-view-container');
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen();
}
};
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'f' || e.key === 'F') {
fullscreenBtn.click();
}
if (e.key === 'm' || e.key === 'M') {
muteBtn.click();
}
if (e.key === 'Escape') {
if ($('appContainer').classList.contains('sidebar-collapsed')) {
toggleSidebar();
}
}
});
// Prevent video interactions
mainVideo.style.pointerEvents = 'none';
mainVideo.addEventListener('contextmenu', e => e.preventDefault());
mainVideo.addEventListener('pause', () => mainVideo.play().catch(() => {}));
// ==================== Init ====================
connect();
</script>
</body>
</html>