utility-scripts/lanshare/static/index.html
Claude 4c223b47ed
Fix screenshare not visible until viewer reloads
When a screenshare was started while both peers were already connected,
the viewer never received the video stream. This worked after a reload
because the peer_joined handler proactively sends an offer to the new
peer.

The root cause: startSharing() relied on a request_stream round-trip
(viewer sends request_stream, sharer responds with offer). Since
handleSignal is async but never awaited by ws.onmessage, this
round-trip could silently fail when promises rejected or messages
interleaved during the exchange.

Fix by having the sharer proactively send offers to all connected peers
in startSharing(), matching the existing peer_joined behavior. Also add
a fallback in ontrack for when event.streams is empty.

https://claude.ai/code/session_01ALSwS4S8EHiP81i2KMsb9Y
2026-02-09 09:16:51 +00:00

1200 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, peerConnection }
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;
if (peer.peerConnection) {
peer.peerConnection.close();
peer.peerConnection = 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,
peerConnection: null,
});
}
function removePeer(peerId) {
const peer = state.peers.get(peerId);
if (peer) {
if (peer.peerConnection) {
peer.peerConnection.close();
}
state.peers.delete(peerId);
if (state.selectedPeerId === peerId) {
state.selectedPeerId = null;
mainVideo.srcObject = null;
}
}
}
// ==================== WebRTC ====================
function createPeerConnection(peerId) {
const pc = new RTCPeerConnection(rtcConfig);
// Debug: Log ICE gathering state changes
pc.onicegatheringstatechange = () => {
console.log(`[${peerId.slice(0,8)}] ICE gathering state: ${pc.iceGatheringState}`);
if (pc.iceGatheringState === 'complete') {
const sdp = pc.localDescription?.sdp || '';
const candidates = extractCandidatesFromSDP(sdp);
console.log(`[${peerId.slice(0,8)}] 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)}] 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)}] 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);
send({
type: 'ice_candidate',
from: state.peerId,
to: peerId,
candidate: JSON.stringify(candidateObj),
});
}
} else {
console.log(`[${peerId.slice(0,8)}] 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)}] 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 connection if we already have one that's not closed
if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') {
console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have connection`);
return;
}
console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(peerId);
peer.peerConnection = 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 connection if any (new offer = renegotiation)
if (peer.peerConnection) {
peer.peerConnection.close();
}
console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(fromPeerId);
peer.peerConnection = 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.peerConnection) {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`);
return;
}
// Only set remote description if we're in the right state
if (peer.peerConnection.signalingState !== 'have-local-offer') {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.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.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
}
function handleIceCandidate(fromPeerId, candidateJson) {
const peer = state.peers.get(fromPeerId);
if (!peer || !peer.peerConnection) {
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
return;
}
// Replace any mDNS .local addresses with the peer's real IP
const candidate = JSON.parse(candidateJson);
candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip);
console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
.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;
// Close all peer connections we initiated
for (const [peerId, peer] of state.peers) {
if (peer.peerConnection) {
peer.peerConnection.close();
peer.peerConnection = 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>