346 lines
13 KiB
JavaScript
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());
|
|
}
|
|
}); |