37055-vm/assets/js/main.js
2025-12-18 20:23:57 +00:00

346 lines
13 KiB
JavaScript

document.addEventListener('DOMContentLoaded', function () {
const API_URL = './api/';
// --- Main UI Elements ---
const selectionContainer = document.getElementById('selection-container');
const hostPanelContainer = document.getElementById('host-panel-container');
const participantPanelContainer = document.getElementById('participant-panel-container');
const toastContainer = document.querySelector('.toast-container');
// --- Room Joining Elements ---
const joinCodeInput = document.getElementById('join-code');
const joinRoomBtn = document.getElementById('join-room-btn');
// --- Room Creation Elements ---
const createRoomBtn = document.getElementById('create-room-btn');
// --- Host Panel Elements ---
const hostRoomCodeDisplay = document.getElementById('host-room-code');
const hostStatusDisplay = document.getElementById('host-status');
const startStreamingBtn = document.getElementById('start-streaming-btn');
const deleteRoomBtn = document.getElementById('delete-room-btn');
// --- Participant Panel Elements ---
const participantStatusDisplay = document.getElementById('participant-status');
const participantAudio = document.getElementById('participant-audio');
const leaveRoomBtn = document.getElementById('leave-room-btn');
// --- State Management ---
let localAudioStream = null;
let peerConnection = null;
let roomCode = null;
let isHost = false;
// ===================================================================
// 1. EVENT LISTENERS
// ===================================================================
if (joinCodeInput) {
joinCodeInput.addEventListener('input', () => {
joinRoomBtn.disabled = joinCodeInput.value.length < 6;
});
}
if (joinRoomBtn) {
joinRoomBtn.addEventListener('click', () => {
const code = joinCodeInput.value.toLowerCase();
joinRoom(code);
});
}
if (createRoomBtn) {
createRoomBtn.addEventListener('click', createRoom);
}
if (startStreamingBtn) {
startStreamingBtn.addEventListener('click', () => {
if (localAudioStream) {
stopStreaming();
} else {
startStreaming();
}
});
}
if (deleteRoomBtn) {
deleteRoomBtn.addEventListener('click', () => {
stopStreaming();
// TODO: Add server-side room deletion
hostPanelContainer.classList.add('d-none');
selectionContainer.classList.remove('d-none');
showToast('Room has been closed.');
});
}
if (leaveRoomBtn) {
leaveRoomBtn.addEventListener('click', () => {
if (peerConnection) {
peerConnection.close();
}
participantPanelContainer.classList.add('d-none');
selectionContainer.classList.remove('d-none');
showToast('You have left the room.');
});
}
// ===================================================================
// 2. API & ROOM MANAGEMENT
// ===================================================================
async function createRoom() {
try {
const response = await fetch(`${API_URL}?action=create-room`);
const data = await response.json();
if (data.roomCode) {
roomCode = data.roomCode;
isHost = true;
hostRoomCodeDisplay.textContent = roomCode;
selectionContainer.classList.add('d-none');
hostPanelContainer.classList.remove('d-none');
showToast('Your room is live!', 'success');
listenForSignals();
}
} catch (error) {
console.error('Error creating room:', error);
showToast('Could not create room. Please try again.', 'danger');
}
}
async function joinRoom(code) {
try {
const response = await fetch(`${API_URL}?action=get-room-details&roomCode=${code}`);
if (!response.ok) throw new Error('Room not found');
const data = await response.json();
roomCode = code;
isHost = false;
selectionContainer.classList.add('d-none');
participantPanelContainer.classList.remove('d-none');
updateParticipantStatus('joining');
await setupParticipantConnection(data);
listenForSignals();
} catch (error) {
console.error('Error joining room:', error);
showToast(`Could not join room: ${error.message}`, 'danger');
}
}
// ===================================================================
// 3. CORE STREAMING LOGIC (WebRTC)
// ===================================================================
async function startStreaming() {
updateHostStatus('connecting');
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
if (stream.getAudioTracks().length === 0) {
stream.getTracks().forEach(track => track.stop());
showToast('No audio was shared. Please enable audio sharing.', 'danger');
updateHostStatus('error');
return;
}
localAudioStream = stream;
updateHostStatus('streaming');
showToast('Streaming started! Waiting for a participant...', 'success');
stream.getAudioTracks()[0].onended = () => stopStreaming();
// --- WebRTC Host Logic ---
peerConnection = createPeerConnection();
stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
await signal({ offer });
} catch (err) {
console.error('Streaming Error:', err);
updateHostStatus('error');
showToast('Could not start streaming.', 'danger');
}
}
function stopStreaming() {
if (localAudioStream) {
localAudioStream.getTracks().forEach(track => track.stop());
localAudioStream = null;
}
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
updateHostStatus('idle');
console.log('Streaming stopped.');
}
async function setupParticipantConnection(roomData) {
peerConnection = createPeerConnection();
peerConnection.ontrack = (event) => {
updateParticipantStatus('playing');
participantAudio.srcObject = event.streams[0];
participantAudio.play();
};
await peerConnection.setRemoteDescription(new RTCSessionDescription(roomData.offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
await signal({ answer });
// Add host's ICE candidates
if (roomData.host_candidates) {
for (const candidate of roomData.host_candidates) {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
}
}
function createPeerConnection() {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.onicecandidate = event => {
if (event.candidate) {
signal({ candidate: event.candidate });
}
};
pc.oniceconnectionstatechange = () => {
console.log('ICE Connection State:', pc.iceConnectionState);
if (isHost) {
if (pc.iceConnectionState === 'connected') {
showToast('A participant has connected!', 'success');
}
} else {
if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
updateParticipantStatus('error');
}
}
};
return pc;
}
async function signal(data) {
await fetch(`${API_URL}?action=signal&roomCode=${roomCode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, isHost })
});
}
// Basic polling to get signals from the other party
async function listenForSignals() {
const interval = setInterval(async () => {
try {
const response = await fetch(`${API_URL}?action=get-room-details&roomCode=${roomCode}`);
const data = await response.json();
if (peerConnection && peerConnection.signalingState === 'stable') return;
if (isHost && data.answer && peerConnection.signalingState !== 'stable') {
await peerConnection.setRemoteDescription(new RTCSessionDescription(data.answer));
// Add participant's ICE candidates
if (data.participant_candidates) {
for (const candidate of data.participant_candidates) {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
}
}
} catch (error) {
// console.error('Signaling listener error:', error);
}
}, 3000);
// Note: In a real app, you'd use WebSockets instead of polling.
}
// ===================================================================
// 4. UI HELPER FUNCTIONS
// ===================================================================
function updateHostStatus(status) {
startStreamingBtn.disabled = false;
let content = '';
switch (status) {
case 'connecting':
startStreamingBtn.disabled = true;
content = `<div class="spinner-border spinner-border-sm"></div><span class="ms-2">Requesting permission...</span>`;
hostStatusDisplay.className = 'alert alert-warning';
break;
case 'streaming':
startStreamingBtn.innerHTML = '<i class="bi bi-mic-mute-fill"></i> Stop Streaming';
startStreamingBtn.className = 'btn btn-danger btn-lg';
content = `<i class="bi bi-broadcast"></i> Now streaming... Waiting for connections.`;
hostStatusDisplay.className = 'alert alert-success';
break;
case 'error':
content = '<i class="bi bi-exclamation-triangle-fill"></i> Could not start streaming.';
hostStatusDisplay.className = 'alert alert-danger';
break;
case 'idle':
default:
startStreamingBtn.innerHTML = '<i class="bi bi-mic-fill"></i> Start Streaming';
startStreamingBtn.className = 'btn btn-success btn-lg';
content = 'Not currently streaming.';
hostStatusDisplay.className = 'alert alert-info';
break;
}
hostStatusDisplay.innerHTML = content;
}
function updateParticipantStatus(status) {
let content = '';
switch (status) {
case 'joining':
content = `<div class="spinner-border"></div><p class="mt-3">Connecting to host...</p>`;
participantStatusDisplay.className = 'alert alert-info';
break;
case 'playing':
content = `<i class="bi bi-volume-up-fill"></i> Live audio is playing.`;
participantStatusDisplay.className = 'alert alert-success';
break;
case 'error':
content = '<i class="bi bi-exclamation-triangle-fill"></i> Connection lost. Please try rejoining.';
participantStatusDisplay.className = 'alert alert-danger';
if (participantAudio.srcObject) {
participantAudio.srcObject.getTracks().forEach(track => track.stop());
participantAudio.srcObject = null;
}
break;
}
participantStatusDisplay.innerHTML = content;
}
function showToast(message, type = 'info') {
if (!toastContainer) return;
const bgClass = {
info: 'bg-primary',
success: 'bg-success',
danger: 'bg-danger'
}[type] || 'bg-primary';
const toastEl = document.createElement('div');
toastEl.className = `toast align-items-center text-white ${bgClass} border-0`;
toastEl.setAttribute('role', 'alert');
toastEl.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
toastContainer.appendChild(toastEl);
const toast = new bootstrap.Toast(toastEl, { delay: 5000 });
toast.show();
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
}
});