utility-scripts/lanshare/static/index.html
Marcel Müller c23d7d06e0 Add local screenshare app
Signed-off-by: Marcel Müller <neikos@neikos.email>
2026-02-04 12:18:48 +01:00

1071 lines
43 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,
peerName: localStorage.getItem('lanshare_name') || null,
peers: new Map(), // peer_id -> { name, isSharing, hasAudio, stream, peerConnection }
localStream: null,
isSharing: false,
includeAudio: true,
selectedPeerId: null,
ws: null,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
};
// WebRTC config - using public STUN servers for ICE
const rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
]
};
// ==================== 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;
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.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, 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)}`, 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':
await handleIceCandidate(msg.from, msg.candidate);
break;
}
}
// ==================== Peer Management ====================
function addPeer(peerId, name, isSharing, hasAudio) {
if (peerId === state.peerId) return;
state.peers.set(peerId, {
name,
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);
pc.onicecandidate = (event) => {
if (event.candidate) {
send({
type: 'ice_candidate',
from: state.peerId,
to: peerId,
candidate: JSON.stringify(event.candidate),
});
}
};
pc.ontrack = (event) => {
console.log('Received track from', peerId);
const peer = state.peers.get(peerId);
if (peer) {
peer.stream = event.streams[0];
updateUI();
// Auto-select if this is the first/only stream
if (!state.selectedPeerId) {
selectPeer(peerId);
}
}
};
pc.onconnectionstatechange = () => {
console.log(`Connection state with ${peerId}: ${pc.connectionState}`);
};
return pc;
}
async function requestStreamFrom(peerId) {
send({
type: 'request_stream',
from: state.peerId,
to: peerId,
});
}
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('sendOfferTo: already have connection to', peerId);
return;
}
console.log('sendOfferTo: creating offer for', peerId);
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);
send({
type: 'offer',
from: state.peerId,
to: peerId,
sdp: JSON.stringify(pc.localDescription),
});
}
async function handleOffer(fromPeerId, sdpJson) {
// Ensure we have this peer in state
if (!state.peers.has(fromPeerId)) {
addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, true, false);
}
const peer = state.peers.get(fromPeerId);
// Close existing connection if any (new offer = renegotiation)
if (peer.peerConnection) {
peer.peerConnection.close();
}
console.log('handleOffer: creating answer for', fromPeerId);
const pc = createPeerConnection(fromPeerId);
peer.peerConnection = pc;
const sdp = JSON.parse(sdpJson);
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
send({
type: 'answer',
from: state.peerId,
to: fromPeerId,
sdp: JSON.stringify(pc.localDescription),
});
}
async function handleAnswer(fromPeerId, sdpJson) {
const peer = state.peers.get(fromPeerId);
if (!peer || !peer.peerConnection) {
console.warn('handleAnswer: no peer connection for', fromPeerId);
return;
}
// Only set remote description if we're in the right state
if (peer.peerConnection.signalingState !== 'have-local-offer') {
console.warn('handleAnswer: wrong state', peer.peerConnection.signalingState, 'for', fromPeerId);
return;
}
console.log('handleAnswer: setting remote description for', fromPeerId);
const sdp = JSON.parse(sdpJson);
await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
}
async function handleIceCandidate(fromPeerId, candidateJson) {
const peer = state.peers.get(fromPeerId);
if (!peer || !peer.peerConnection) {
console.warn('handleIceCandidate: no peer connection for', fromPeerId);
return;
}
try {
const candidate = JSON.parse(candidateJson);
await peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
} catch (err) {
console.warn('handleIceCandidate: error adding 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 - other peers will send request_stream in response
send({
type: 'started_sharing',
peer_id: state.peerId,
has_audio: stream.getAudioTracks().length > 0,
});
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>