utility-scripts/lanshare/static/index.html
Claude 6ca45de838
Split peerConnection into separate outgoing/incoming connections
The root cause of both bugs was that each peer had a single
peerConnection field used for both sending and receiving streams:

1. Starting a share while receiving another's stream failed because
   sendOfferTo() saw the existing incoming PC and bailed out, so
   the outgoing stream was never sent.

2. Stopping a share killed the other person's stream because
   stopSharing() closed ALL peer connections, including the incoming
   one used to receive their stream.

Fix by splitting into outgoingPC (created via sendOfferTo, used for
sending our stream) and incomingPC (created via handleOffer, used for
receiving their stream). ICE candidates now embed a direction flag so
the remote side routes them to the correct PC.

https://claude.ai/code/session_01ALSwS4S8EHiP81i2KMsb9Y
2026-02-09 09:31:08 +00:00

1215 lines
50 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);
state.ws.onopen = () => {
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 = () => {
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) => {
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
startShareBtn.classList.toggle('hidden', state.isSharing);
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>