Mobile browsers typically don't support screen sharing. Hide the start-sharing button entirely when navigator.mediaDevices.getDisplayMedia is not available. https://claude.ai/code/session_01ALSwS4S8EHiP81i2KMsb9Y
1216 lines
50 KiB
HTML
1216 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 (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>
|