Spaces:
Build error
Build error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Simple Video Call</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"></script> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Video Call</h1> | |
| <div class="video-grid"> | |
| <div> | |
| <h2>Local Video</h2> | |
| <video id="localVideo" autoplay muted playsinline></video> | |
| </div> | |
| <div> | |
| <h2>Remote Video</h2> | |
| <video id="remoteVideo" autoplay playsinline></video> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <button id="startButton">Start Call</button> | |
| <input id="sessionInput" placeholder="Enter session ID to join"> | |
| <button id="joinButton">Join Call</button> | |
| <button id="endButton">End Call</button> | |
| <div> | |
| <label for="sessionId">Session ID:</label> | |
| <input id="sessionId" readonly> | |
| <button id="copyButton">Copy</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const APP_ID = "d8f5af0fe38ea134ebd03e7bf0a8ae14"; // Thay thế bằng APP_ID của bạn | |
| const APP_TOKEN = "3d9031d1c83112f68636517a1b4c765161bf0b7667fe9a665b528f760b1f4f28"; // Thay thế bằng APP_TOKEN của bạn | |
| const API_BASE = `https://rtc.live.cloudflare.com/v1/apps/${APP_ID}`; | |
| let localStream; | |
| let localPeerConnection; | |
| let localSessionId; | |
| let remoteSessionId = null; // Thêm biến để lưu session id của người tham gia | |
| const localVideo = document.getElementById('localVideo'); | |
| const remoteVideo = document.getElementById('remoteVideo'); | |
| const startButton = document.getElementById('startButton'); | |
| const endButton = document.getElementById('endButton'); | |
| const sessionInput = document.getElementById('sessionInput'); | |
| const joinButton = document.getElementById('joinButton'); | |
| const sessionIdInput = document.getElementById('sessionId'); | |
| const copyButton = document.getElementById('copyButton'); | |
| startButton.onclick = startCall; | |
| endButton.onclick = endCall; | |
| joinButton.onclick = joinCall; | |
| copyButton.onclick = () => { | |
| sessionIdInput.select(); | |
| document.execCommand('copy'); | |
| alert('Session ID copied to clipboard'); | |
| }; | |
| async function createSession() { | |
| const response = await fetch(`${API_BASE}/sessions/new`, { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}`, | |
| "Content-Type": "application/json" | |
| } | |
| }).then(res => res.json()); | |
| if (response.errorCode) { | |
| throw new Error(response.errorDescription); | |
| } | |
| return response.sessionId; | |
| } | |
| async function createPeerConnection() { | |
| const peerConnection = new RTCPeerConnection({ | |
| iceServers: [{ | |
| urls: "stun:stun.cloudflare.com:3478" | |
| }], | |
| bundlePolicy: "max-bundle" | |
| }); | |
| // Add ICE candidate handling | |
| peerConnection.onicecandidate = (event) => { | |
| if (event.candidate) { | |
| console.log("New ICE candidate:", event.candidate); | |
| } | |
| }; | |
| // Add connection state handling | |
| peerConnection.onconnectionstatechange = (event) => { | |
| console.log("Connection state:", peerConnection.connectionState); | |
| }; | |
| // Add track handling | |
| peerConnection.ontrack = (event) => { | |
| console.log("Received remote track:", event); | |
| if (remoteVideo.srcObject !== event.streams[0]) { | |
| remoteVideo.srcObject = event.streams[0]; | |
| } | |
| }; | |
| return peerConnection; | |
| } | |
| async function startCall() { | |
| try { | |
| // Get local media stream | |
| localStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: true, | |
| video: true | |
| }); | |
| localVideo.srcObject = localStream; | |
| // Create new session | |
| localSessionId = await createSession(); | |
| localPeerConnection = await createPeerConnection(); | |
| // Add tracks using addTransceiver API like in demo.html | |
| const transceivers = localStream.getTracks().map(track => | |
| localPeerConnection.addTransceiver(track, { | |
| direction: "sendonly" | |
| }) | |
| ); | |
| // Create and set local offer | |
| const offer = await localPeerConnection.createOffer(); | |
| await localPeerConnection.setLocalDescription(offer); | |
| // Format request according to API schema | |
| const requestBody = { | |
| sessionDescription: { | |
| sdp: offer.sdp, | |
| type: "offer" | |
| }, | |
| tracks: transceivers.map(({mid, sender}) => ({ | |
| location: "local", | |
| mid: mid, | |
| trackName: sender.track?.id | |
| })) | |
| }; | |
| // Set up ICE connection state handler | |
| const connected = new Promise((resolve, reject) => { | |
| setTimeout(() => reject(new Error("ICE connection timeout")), 5000); | |
| const iceConnectionStateChangeHandler = () => { | |
| if (localPeerConnection.iceConnectionState === "connected") { | |
| localPeerConnection.removeEventListener( | |
| "iceconnectionstatechange", | |
| iceConnectionStateChangeHandler | |
| ); | |
| resolve(); | |
| } | |
| }; | |
| localPeerConnection.addEventListener( | |
| "iceconnectionstatechange", | |
| iceConnectionStateChangeHandler | |
| ); | |
| }); | |
| // Send tracks to API | |
| const response = await fetch(`${API_BASE}/sessions/${localSessionId}/tracks/new`, { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API error: ${response.status}`); | |
| } | |
| const responseData = await response.json(); | |
| // Set remote description from response | |
| await localPeerConnection.setRemoteDescription( | |
| new RTCSessionDescription(responseData.sessionDescription) | |
| ); | |
| // Wait for ICE connection | |
| await connected; | |
| // Update UI | |
| sessionIdInput.value = localSessionId; | |
| startSessionCheck(localSessionId); | |
| } catch (err) { | |
| console.error("Error starting call:", err); | |
| alert("Error starting call: " + err.message); | |
| } | |
| } | |
| async function joinCall() { | |
| try { | |
| const sessionToJoin = sessionInput.value.trim(); | |
| if (!sessionToJoin) { | |
| alert('Please enter a session ID'); | |
| return; | |
| } | |
| // Get session state first | |
| const sessionState = await fetch(`${API_BASE}/sessions/${sessionToJoin}`, { | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}` | |
| } | |
| }).then(res => res.json()); | |
| console.log("Session state:", sessionState); | |
| if (!sessionState.tracks || sessionState.tracks.length === 0) { | |
| throw new Error("No active tracks in session"); | |
| } | |
| // Initialize local resources | |
| localStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: true, | |
| video: true | |
| }); | |
| localVideo.srcObject = localStream; | |
| localPeerConnection = await createPeerConnection(); | |
| localSessionId = await createSession(); | |
| // First add our local tracks | |
| const localTransceivers = localStream.getTracks().map(track => | |
| localPeerConnection.addTransceiver(track, { | |
| direction: "sendonly" | |
| }) | |
| ); | |
| // Create and set local offer for our tracks | |
| const localOffer = await localPeerConnection.createOffer(); | |
| await localPeerConnection.setLocalDescription(localOffer); | |
| // Send our tracks first | |
| const addLocalTracksResponse = await fetch(`${API_BASE}/sessions/${localSessionId}/tracks/new`, { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify({ | |
| sessionDescription: { | |
| sdp: localOffer.sdp, | |
| type: "offer" | |
| }, | |
| tracks: localTransceivers.map(({mid, sender}) => ({ | |
| location: "local", | |
| mid: mid, | |
| trackName: sender.track?.id | |
| })) | |
| }) | |
| }).then(res => res.json()); | |
| // Handle local tracks response | |
| await localPeerConnection.setRemoteDescription( | |
| new RTCSessionDescription(addLocalTracksResponse.sessionDescription) | |
| ); | |
| // Now pull the remote tracks | |
| const pullRequest = { | |
| tracks: sessionState.tracks | |
| .filter(track => track.status === 'active') | |
| .map(track => ({ | |
| location: "remote", | |
| sessionId: sessionToJoin, | |
| trackName: track.trackName | |
| })) | |
| }; | |
| // Set up track handling for remote tracks | |
| const resolvingTracks = new Promise((resolve, reject) => { | |
| let receivedTracks = []; | |
| const timeout = setTimeout(() => reject(new Error("Track timeout")), 10000); | |
| const handleTrack = (event) => { | |
| console.log("Received track:", event.track); | |
| receivedTracks.push(event.track); | |
| if (receivedTracks.length === pullRequest.tracks.length) { | |
| clearTimeout(timeout); | |
| localPeerConnection.removeEventListener('track', handleTrack); | |
| resolve(receivedTracks); | |
| } | |
| }; | |
| localPeerConnection.addEventListener('track', handleTrack); | |
| }); | |
| // Pull remote tracks | |
| const pullResponse = await fetch(`${API_BASE}/sessions/${localSessionId}/tracks/new`, { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify(pullRequest) | |
| }).then(res => res.json()); | |
| // Handle remote tracks | |
| if (pullResponse.requiresImmediateRenegotiation) { | |
| await localPeerConnection.setRemoteDescription( | |
| new RTCSessionDescription(pullResponse.sessionDescription) | |
| ); | |
| const answer = await localPeerConnection.createAnswer(); | |
| await localPeerConnection.setLocalDescription(answer); | |
| await fetch(`${API_BASE}/sessions/${localSessionId}/renegotiate`, { | |
| method: "PUT", | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify({ | |
| sessionDescription: { | |
| sdp: answer.sdp, | |
| type: "answer" | |
| } | |
| }) | |
| }).then(res => res.json()); | |
| } | |
| // Wait for and display remote tracks | |
| const pulledTracks = await resolvingTracks; | |
| const remoteStream = new MediaStream(); | |
| pulledTracks.forEach(track => remoteStream.addTrack(track)); | |
| remoteVideo.srcObject = remoteStream; | |
| startSessionCheck(localSessionId); | |
| startSessionCheck(sessionToJoin); | |
| } catch (err) { | |
| console.error("Error joining call:", err); | |
| alert("Error joining call: " + err.message); | |
| } | |
| } | |
| async function endCall() { | |
| try { | |
| if (localSessionId) { | |
| // Close tracks following schema | |
| await fetch(`${API_BASE}/sessions/${localSessionId}/tracks/close`, { | |
| method: "PUT", | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify({ | |
| tracks: [], // Close all tracks | |
| force: true // Force close without renegotiation | |
| }) | |
| }); | |
| } | |
| // Clean up resources | |
| if (localStream) { | |
| localStream.getTracks().forEach(track => track.stop()); | |
| } | |
| if (localPeerConnection) { | |
| localPeerConnection.close(); | |
| } | |
| localVideo.srcObject = null; | |
| remoteVideo.srcObject = null; | |
| sessionIdInput.value = ''; | |
| sessionInput.value = ''; | |
| } catch (err) { | |
| console.error("Error ending call:", err); | |
| } | |
| } | |
| // Sửa lại hàm startSessionCheck để thêm xử lý khi có người tham gia | |
| function startSessionCheck(sessionId) { | |
| const checkInterval = setInterval(async () => { | |
| try { | |
| const response = await fetch(`${API_BASE}/sessions/${sessionId}`, { | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}` | |
| } | |
| }).then(res => res.json()); | |
| if (response.errorCode) { | |
| clearInterval(checkInterval); | |
| console.log(`Session ${sessionId} ended`); | |
| return; | |
| } | |
| // Kiểm tra nếu là session gốc và có tracks mới | |
| if (sessionId === localSessionId && response.tracks) { | |
| const newTracks = response.tracks.filter(track => | |
| track.status === 'active' && | |
| track.sessionId !== localSessionId && | |
| track.sessionId !== remoteSessionId | |
| ); | |
| if (newTracks.length > 0) { | |
| console.log("New participant joined, pulling their tracks"); | |
| remoteSessionId = newTracks[0].sessionId; | |
| await pullRemoteTracks(newTracks); | |
| } | |
| } | |
| } catch (err) { | |
| clearInterval(checkInterval); | |
| console.error("Session check failed:", err); | |
| } | |
| }, 5000); | |
| } | |
| // Thêm hàm mới để pull tracks từ người tham gia | |
| async function pullRemoteTracks(tracks) { | |
| try { | |
| // Set up track handling | |
| const resolvingTracks = new Promise((resolve, reject) => { | |
| let receivedTracks = []; | |
| const timeout = setTimeout(() => reject(new Error("Track timeout")), 10000); | |
| const handleTrack = (event) => { | |
| console.log("Received remote participant track:", event.track); | |
| receivedTracks.push(event.track); | |
| if (receivedTracks.length === tracks.length) { | |
| clearTimeout(timeout); | |
| localPeerConnection.removeEventListener('track', handleTrack); | |
| resolve(receivedTracks); | |
| } | |
| }; | |
| localPeerConnection.addEventListener('track', handleTrack); | |
| }); | |
| // Pull remote tracks | |
| const pullResponse = await fetch(`${API_BASE}/sessions/${localSessionId}/tracks/new`, { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify({ | |
| tracks: tracks.map(track => ({ | |
| location: "remote", | |
| sessionId: track.sessionId, | |
| trackName: track.trackName | |
| })) | |
| }) | |
| }).then(res => res.json()); | |
| if (pullResponse.requiresImmediateRenegotiation) { | |
| await localPeerConnection.setRemoteDescription( | |
| new RTCSessionDescription(pullResponse.sessionDescription) | |
| ); | |
| const answer = await localPeerConnection.createAnswer(); | |
| await localPeerConnection.setLocalDescription(answer); | |
| await fetch(`${API_BASE}/sessions/${localSessionId}/renegotiate`, { | |
| method: "PUT", | |
| headers: { | |
| "Authorization": `Bearer ${APP_TOKEN}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify({ | |
| sessionDescription: { | |
| sdp: answer.sdp, | |
| type: "answer" | |
| } | |
| }) | |
| }).then(res => res.json()); | |
| } | |
| // Wait for and display remote tracks | |
| const pulledTracks = await resolvingTracks; | |
| const remoteStream = remoteVideo.srcObject instanceof MediaStream ? | |
| remoteVideo.srcObject : new MediaStream(); | |
| pulledTracks.forEach(track => remoteStream.addTrack(track)); | |
| remoteVideo.srcObject = remoteStream; | |
| } catch (err) { | |
| console.error("Error pulling remote tracks:", err); | |
| } | |
| } | |
| </script> | |
| <style> | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| } | |
| .video-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 20px; | |
| margin: 20px 0; | |
| } | |
| video { | |
| width: 100%; | |
| background: #333; | |
| } | |
| .controls { | |
| text-align: center; | |
| } | |
| button { | |
| padding: 10px 20px; | |
| margin: 0 10px; | |
| } | |
| .controls div { | |
| margin-top: 10px; | |
| } | |
| #sessionId { | |
| width: 200px; | |
| margin-right: 10px; | |
| } | |
| </style> | |
| </body> | |
| </html> |