Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ParthMeet - Professional Video Meetings</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(20px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| @keyframes slideIn { | |
| from { opacity: 0; transform: translateX(-20px); } | |
| to { opacity: 1; transform: translateX(0); } | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .fade-in { animation: fadeIn 0.5s ease-out; } | |
| .slide-in { animation: slideIn 0.3s ease-out; } | |
| .pulse-animation { animation: pulse 2s infinite; } | |
| .video-container { | |
| position: relative; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| border-radius: 12px; | |
| overflow: hidden; | |
| aspect-ratio: 16/9; | |
| } | |
| video { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .control-btn { | |
| transition: all 0.2s ease; | |
| } | |
| .control-btn:hover { | |
| transform: scale(1.1); | |
| } | |
| .control-btn:active { | |
| transform: scale(0.95); | |
| } | |
| .gradient-bg { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| } | |
| .glass-effect { | |
| background: rgba(255, 255, 255, 0.1); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <!-- Home Screen --> | |
| <div id="homeScreen" class="min-h-screen flex items-center justify-center p-4"> | |
| <div class="max-w-4xl w-full fade-in"> | |
| <div class="text-center mb-12"> | |
| <h1 class="text-6xl font-bold text-gray-900 mb-4"> | |
| Parth<span class="gradient-bg bg-clip-text text-transparent">Meet</span> | |
| </h1> | |
| <p class="text-xl text-gray-600">Professional video meetings made simple- Developed by Parthmax(Saksham Pathak)</p> | |
| </div> | |
| <div class="grid md:grid-cols-2 gap-6"> | |
| <!-- Create Meeting Card --> | |
| <div class="bg-white rounded-2xl shadow-lg p-8 hover:shadow-xl transition-shadow"> | |
| <div class="w-16 h-16 gradient-bg rounded-full flex items-center justify-center mb-6"> | |
| <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path> | |
| </svg> | |
| </div> | |
| <h2 class="text-2xl font-bold text-gray-900 mb-3">Create Meeting</h2> | |
| <p class="text-gray-600 mb-6">Start a new video meeting instantly</p> | |
| <input type="text" id="hostName" placeholder="Your name" | |
| class="w-full px-4 py-3 border border-gray-300 rounded-lg mb-4 focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none"> | |
| <button onclick="createMeeting()" | |
| class="w-full gradient-bg text-white py-3 rounded-lg font-semibold hover:opacity-90 transition-opacity"> | |
| Create Meeting | |
| </button> | |
| </div> | |
| <!-- Join Meeting Card --> | |
| <div class="bg-white rounded-2xl shadow-lg p-8 hover:shadow-xl transition-shadow"> | |
| <div class="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mb-6"> | |
| <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path> | |
| </svg> | |
| </div> | |
| <h2 class="text-2xl font-bold text-gray-900 mb-3">Join Meeting</h2> | |
| <p class="text-gray-600 mb-6">Enter a meeting code to join</p> | |
| <input type="text" id="participantName" placeholder="Your name" | |
| class="w-full px-4 py-3 border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-green-500 focus:border-transparent outline-none"> | |
| <input type="text" id="meetingCode" placeholder="Meeting code" | |
| class="w-full px-4 py-3 border border-gray-300 rounded-lg mb-4 focus:ring-2 focus:ring-green-500 focus:border-transparent outline-none"> | |
| <button onclick="joinMeeting()" | |
| class="w-full bg-green-500 text-white py-3 rounded-lg font-semibold hover:bg-green-600 transition-colors"> | |
| Join Meeting | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Meeting Screen --> | |
| <div id="meetingScreen" class="hidden min-h-screen bg-gray-900 flex flex-col"> | |
| <!-- Header --> | |
| <div class="bg-gray-800 border-b border-gray-700 px-6 py-4"> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex items-center gap-4"> | |
| <h2 class="text-white font-semibold text-lg">ParthMeet</h2> | |
| <div class="flex items-center gap-2 glass-effect px-3 py-1 rounded-full"> | |
| <div class="w-2 h-2 bg-green-500 rounded-full pulse-animation"></div> | |
| <span class="text-gray-300 text-sm" id="meetingId">Meeting ID</span> | |
| </div> | |
| </div> | |
| <button onclick="copyMeetingId()" | |
| class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors text-sm"> | |
| Copy Meeting ID | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Video Grid --> | |
| <div class="flex-1 p-6 overflow-auto"> | |
| <div id="videoGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-7xl mx-auto"> | |
| <!-- Local Video --> | |
| <div class="video-container relative group"> | |
| <video id="localVideo" autoplay muted playsinline></video> | |
| <div class="absolute bottom-4 left-4 bg-black bg-opacity-50 px-3 py-1 rounded-lg"> | |
| <span class="text-white text-sm font-medium" id="localName">You</span> | |
| </div> | |
| <div id="localMutedIndicator" class="hidden absolute top-4 right-4 bg-red-500 rounded-full p-2"> | |
| <svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20"> | |
| <path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd"></path> | |
| </svg> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Controls --> | |
| <div class="bg-gray-800 border-t border-gray-700 px-6 py-6"> | |
| <div class="flex items-center justify-center gap-4"> | |
| <button id="micBtn" onclick="toggleMic()" | |
| class="control-btn w-14 h-14 bg-gray-700 hover:bg-gray-600 rounded-full flex items-center justify-center"> | |
| <svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path> | |
| </svg> | |
| </button> | |
| <button id="videoBtn" onclick="toggleVideo()" | |
| class="control-btn w-14 h-14 bg-gray-700 hover:bg-gray-600 rounded-full flex items-center justify-center"> | |
| <svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path> | |
| </svg> | |
| </button> | |
| <button onclick="toggleScreenShare()" | |
| class="control-btn w-14 h-14 bg-gray-700 hover:bg-gray-600 rounded-full flex items-center justify-center"> | |
| <svg class="w-6 h-6 text-white" 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"></path> | |
| </svg> | |
| </button> | |
| <button onclick="endMeeting()" | |
| class="control-btn w-14 h-14 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center"> | |
| <svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let localStream = null; | |
| let screenStream = null; | |
| let peerConnections = {}; | |
| let ws = null; | |
| let currentMeetingId = null; | |
| let participantId = null; | |
| let isMicOn = true; | |
| let isVideoOn = true; | |
| let isScreenSharing = false; | |
| const config = { | |
| iceServers: [ | |
| { urls: 'stun:stun.l.google.com:19302' }, | |
| { urls: 'stun:stun1.l.google.com:19302' } | |
| ] | |
| }; | |
| async function createMeeting() { | |
| const hostName = document.getElementById('hostName').value.trim(); | |
| if (!hostName) { | |
| alert('Please enter your name'); | |
| return; | |
| } | |
| try { | |
| const response = await fetch('/api/meeting/create', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ host_name: hostName }) | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| currentMeetingId = data.meeting_id; | |
| participantId = 'host_' + Date.now(); | |
| await initializeMeeting(hostName); | |
| } | |
| } catch (error) { | |
| alert('Failed to create meeting: ' + error.message); | |
| } | |
| } | |
| async function joinMeeting() { | |
| const participantName = document.getElementById('participantName').value.trim(); | |
| const meetingCode = document.getElementById('meetingCode').value.trim(); | |
| if (!participantName || !meetingCode) { | |
| alert('Please enter your name and meeting code'); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`/api/meeting/${meetingCode}`); | |
| const data = await response.json(); | |
| if (data.success) { | |
| currentMeetingId = meetingCode; | |
| participantId = 'participant_' + Date.now(); | |
| await initializeMeeting(participantName); | |
| } | |
| } catch (error) { | |
| alert('Meeting not found or has ended'); | |
| } | |
| } | |
| async function initializeMeeting(userName) { | |
| try { | |
| localStream = await navigator.mediaDevices.getUserMedia({ | |
| video: true, | |
| audio: true | |
| }); | |
| document.getElementById('localVideo').srcObject = localStream; | |
| document.getElementById('localName').textContent = userName; | |
| document.getElementById('meetingId').textContent = currentMeetingId; | |
| document.getElementById('homeScreen').classList.add('hidden'); | |
| document.getElementById('meetingScreen').classList.remove('hidden'); | |
| connectWebSocket(); | |
| } catch (error) { | |
| alert('Failed to access camera/microphone: ' + error.message); | |
| } | |
| } | |
| function connectWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| ws = new WebSocket(`${protocol}//${window.location.host}/ws/${currentMeetingId}/${participantId}`); | |
| ws.onmessage = async (event) => { | |
| const message = JSON.parse(event.data); | |
| switch (message.type) { | |
| case 'participant_joined': | |
| await handleParticipantJoined(message.data.participant_id); | |
| break; | |
| case 'participant_left': | |
| handleParticipantLeft(message.data.participant_id); | |
| break; | |
| case 'offer': | |
| await handleOffer(message); | |
| break; | |
| case 'answer': | |
| await handleAnswer(message); | |
| break; | |
| case 'ice_candidate': | |
| await handleIceCandidate(message); | |
| break; | |
| case 'meeting_ended': | |
| alert(message.data.message); | |
| window.location.reload(); | |
| break; | |
| } | |
| }; | |
| } | |
| async function handleParticipantJoined(remoteId) { | |
| if (remoteId === participantId) return; | |
| const pc = new RTCPeerConnection(config); | |
| peerConnections[remoteId] = pc; | |
| localStream.getTracks().forEach(track => { | |
| pc.addTrack(track, localStream); | |
| }); | |
| pc.onicecandidate = (event) => { | |
| if (event.candidate) { | |
| ws.send(JSON.stringify({ | |
| type: 'ice_candidate', | |
| candidate: event.candidate, | |
| from: participantId, | |
| target: remoteId | |
| })); | |
| } | |
| }; | |
| pc.ontrack = (event) => { | |
| addRemoteVideo(remoteId, event.streams[0]); | |
| }; | |
| const offer = await pc.createOffer(); | |
| await pc.setLocalDescription(offer); | |
| ws.send(JSON.stringify({ | |
| type: 'offer', | |
| offer: offer, | |
| from: participantId, | |
| target: remoteId | |
| })); | |
| } | |
| async function handleOffer(message) { | |
| const remoteId = message.from; | |
| const pc = new RTCPeerConnection(config); | |
| peerConnections[remoteId] = pc; | |
| localStream.getTracks().forEach(track => { | |
| pc.addTrack(track, localStream); | |
| }); | |
| pc.onicecandidate = (event) => { | |
| if (event.candidate) { | |
| ws.send(JSON.stringify({ | |
| type: 'ice_candidate', | |
| candidate: event.candidate, | |
| from: participantId, | |
| target: remoteId | |
| })); | |
| } | |
| }; | |
| pc.ontrack = (event) => { | |
| addRemoteVideo(remoteId, event.streams[0]); | |
| }; | |
| await pc.setRemoteDescription(new RTCSessionDescription(message.offer)); | |
| const answer = await pc.createAnswer(); | |
| await pc.setLocalDescription(answer); | |
| ws.send(JSON.stringify({ | |
| type: 'answer', | |
| answer: answer, | |
| from: participantId, | |
| target: remoteId | |
| })); | |
| } | |
| async function handleAnswer(message) { | |
| const pc = peerConnections[message.from]; | |
| if (pc) { | |
| await pc.setRemoteDescription(new RTCSessionDescription(message.answer)); | |
| } | |
| } | |
| async function handleIceCandidate(message) { | |
| const pc = peerConnections[message.from]; | |
| if (pc) { | |
| await pc.addIceCandidate(new RTCIceCandidate(message.candidate)); | |
| } | |
| } | |
| function addRemoteVideo(remoteId, stream) { | |
| let videoContainer = document.getElementById(`video_${remoteId}`); | |
| if (!videoContainer) { | |
| const grid = document.getElementById('videoGrid'); | |
| videoContainer = document.createElement('div'); | |
| videoContainer.id = `video_${remoteId}`; | |
| videoContainer.className = 'video-container relative group slide-in'; | |
| videoContainer.innerHTML = ` | |
| <video autoplay playsinline></video> | |
| <div class="absolute bottom-4 left-4 bg-black bg-opacity-50 px-3 py-1 rounded-lg"> | |
| <span class="text-white text-sm font-medium">Participant</span> | |
| </div> | |
| `; | |
| grid.appendChild(videoContainer); | |
| } | |
| videoContainer.querySelector('video').srcObject = stream; | |
| } | |
| function handleParticipantLeft(remoteId) { | |
| if (peerConnections[remoteId]) { | |
| peerConnections[remoteId].close(); | |
| delete peerConnections[remoteId]; | |
| } | |
| const videoContainer = document.getElementById(`video_${remoteId}`); | |
| if (videoContainer) { | |
| videoContainer.remove(); | |
| } | |
| } | |
| function toggleMic() { | |
| isMicOn = !isMicOn; | |
| localStream.getAudioTracks()[0].enabled = isMicOn; | |
| const btn = document.getElementById('micBtn'); | |
| btn.classList.toggle('bg-red-600', !isMicOn); | |
| btn.classList.toggle('bg-gray-700', isMicOn); | |
| document.getElementById('localMutedIndicator').classList.toggle('hidden', isMicOn); | |
| } | |
| function toggleVideo() { | |
| isVideoOn = !isVideoOn; | |
| localStream.getVideoTracks()[0].enabled = isVideoOn; | |
| const btn = document.getElementById('videoBtn'); | |
| btn.classList.toggle('bg-red-600', !isVideoOn); | |
| btn.classList.toggle('bg-gray-700', isVideoOn); | |
| } | |
| async function toggleScreenShare() { | |
| if (!isScreenSharing) { | |
| try { | |
| screenStream = await navigator.mediaDevices.getDisplayMedia({ | |
| video: true | |
| }); | |
| const screenTrack = screenStream.getVideoTracks()[0]; | |
| Object.values(peerConnections).forEach(pc => { | |
| const sender = pc.getSenders().find(s => s.track.kind === 'video'); | |
| if (sender) sender.replaceTrack(screenTrack); | |
| }); | |
| document.getElementById('localVideo').srcObject = screenStream; | |
| isScreenSharing = true; | |
| screenTrack.onended = () => { | |
| stopScreenShare(); | |
| }; | |
| } catch (error) { | |
| console.error('Screen share error:', error); | |
| } | |
| } else { | |
| stopScreenShare(); | |
| } | |
| } | |
| function stopScreenShare() { | |
| if (screenStream) { | |
| screenStream.getTracks().forEach(track => track.stop()); | |
| const videoTrack = localStream.getVideoTracks()[0]; | |
| Object.values(peerConnections).forEach(pc => { | |
| const sender = pc.getSenders().find(s => s.track.kind === 'video'); | |
| if (sender) sender.replaceTrack(videoTrack); | |
| }); | |
| document.getElementById('localVideo').srcObject = localStream; | |
| isScreenSharing = false; | |
| } | |
| } | |
| function copyMeetingId() { | |
| navigator.clipboard.writeText(currentMeetingId); | |
| const btn = event.target; | |
| const originalText = btn.textContent; | |
| btn.textContent = 'Copied!'; | |
| setTimeout(() => { | |
| btn.textContent = originalText; | |
| }, 2000); | |
| } | |
| async function endMeeting() { | |
| if (confirm('Are you sure you want to leave the meeting?')) { | |
| if (localStream) { | |
| localStream.getTracks().forEach(track => track.stop()); | |
| } | |
| if (screenStream) { | |
| screenStream.getTracks().forEach(track => track.stop()); | |
| } | |
| if (ws) { | |
| ws.close(); | |
| } | |
| Object.values(peerConnections).forEach(pc => pc.close()); | |
| window.location.reload(); | |
| } | |
| } | |
| window.addEventListener('beforeunload', () => { | |
| if (localStream) { | |
| localStream.getTracks().forEach(track => track.stop()); | |
| } | |
| if (ws) { | |
| ws.close(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |