testspace / index.js
alpha1ct's picture
Upload 3 files
bec56cb verified
/**
* Initializes a new instance of the `DailyCallManager` class, creating
* a Daily.co call object and setting initial states for camera and
* microphone muting, as well as the current room URL. It then calls the
* `initialize` method to set up event listeners and UI interactions.
*/
class DailyCallManager {
constructor() {
this.call = Daily.createCallObject();
this.currentRoomUrl = null;
this.initialize();
}
/**
* Performs initial setup of event listeners and UI component interactions.
*/
async initialize() {
this.setupEventListeners();
document
.getElementById("toggle-camera")
.addEventListener("click", () => this.toggleCamera());
document
.getElementById("toggle-mic")
.addEventListener("click", () => this.toggleMicrophone());
}
/**
* Configures event listeners for various call-related events.
*/
setupEventListeners() {
const events = {
"active-speaker-change": this.handleActiveSpeakerChange.bind(this),
error: this.handleError.bind(this),
"joined-meeting": this.handleJoin.bind(this),
"left-meeting": this.handleLeave.bind(this),
"participant-joined": this.handleParticipantJoinedOrUpdated.bind(this),
"participant-left": this.handleParticipantLeft.bind(this),
"participant-updated": this.handleParticipantJoinedOrUpdated.bind(this),
};
Object.entries(events).forEach(([event, handler]) => {
this.call.on(event, handler);
});
}
/**
* Handler for the local participant joining:
* - Prints the room URL
* - Enables the toggle camera, toggle mic, and leave buttons
* - Gets the initial track states
* - Sets up and enables the device selectors
* @param {Object} event - The joined-meeting event object.
*/
handleJoin(event) {
const tracks = event.participants.local.tracks;
console.log(`Successfully joined: ${this.currentRoomUrl}`);
// Update the participant count
this.updateAndDisplayParticipantCount();
// Enable the leave button
document.getElementById("leave-btn").disabled = false;
// Enable the toggle camera and mic buttons and selectors
document.getElementById("toggle-camera").disabled = false;
document.getElementById("toggle-mic").disabled = false;
document.getElementById("camera-selector").disabled = false;
document.getElementById("mic-selector").disabled = false;
// Set up the camera and mic selectors
this.setupDeviceSelectors();
// Initialize the camera and microphone states and UI for the local
// participant
Object.entries(tracks).forEach(([trackType, trackInfo]) => {
this.updateUiForDevicesState(trackType, trackInfo);
});
}
/**
* Handler for participant leave events:
* - Confirms leaving with a console message
* - Disable the toggle camera and mic buttons
* - Resets the camera and mic selectors
* - Updates the call state in the UI
* - Removes all video containers
*/
handleLeave() {
console.log("Successfully left the call");
// Update the join and leave button states
document.getElementById("leave-btn").disabled = true;
document.getElementById("join-btn").disabled = false;
// Disable the toggle camera and mic buttons
document.getElementById("toggle-camera").disabled = true;
document.getElementById("toggle-mic").disabled = true;
// Reset and disable the camera and mic selectors
const cameraSelector = document.getElementById("camera-selector");
const micSelector = document.getElementById("mic-selector");
cameraSelector.selectedIndex = 0;
micSelector.selectedIndex = 0;
cameraSelector.disabled = true;
micSelector.disabled = true;
// Update the call state in the UI
document.getElementById("camera-state").textContent = "Camera: Off";
document.getElementById("mic-state").textContent = "Mic: Off";
document.getElementById(
"participant-count"
).textContent = `Participants: 0`;
document.getElementById(
"active-speaker"
).textContent = `Active Speaker: None`;
// Remove all video containers
const videosDiv = document.getElementById("videos");
while (videosDiv.firstChild) {
videosDiv.removeChild(videosDiv.firstChild);
}
}
/**
* Handles fatal errors emitted from the Daily call object.
* These errors result in the participant leaving the meeting. A
* `left-meeting` event will also be sent, so we still rely on that event
* for cleanup.
* @param {Object} e - The error event object.
*/
handleError(e) {
console.error("DAILY SENT AN ERROR!", e.error ? e.error : e.errorMsg);
}
/**
* Handles participant-left event:
* - Cleans up the video and audio tracks for the participant
* - Removes the related UI elements
* @param {Object} event - The participant-left event object.
*/
handleParticipantLeft(event) {
const participantId = event.participant.session_id;
// Clean up the video and audio tracks for the participant
this.destroyTracks(["video", "audio"], participantId);
// Now, remove the related video UI
document.getElementById(`video-container-${participantId}`)?.remove();
// Update the participant count
this.updateAndDisplayParticipantCount();
}
/**
* Handles participant-joined and participant-updated events:
* - Updates the participant count
* - Creates a video container for new participants
* - Creates an audio element for new participants
* - Manages video and audio tracks based on their current state
* - Updates device states for the local participant
* @param {Object} event - The participant-joined, participant-updated
* event object.
*/
handleParticipantJoinedOrUpdated(event) {
const { participant } = event;
const participantId = participant.session_id;
const isLocal = participant.local;
const tracks = participant.tracks;
// Always update the participant count regardless of the event action
this.updateAndDisplayParticipantCount();
// Create a video container if one doesn't exist
if (!document.getElementById(`video-container-${participantId}`)) {
this.createVideoContainer(participantId);
}
// Create an audio element for non-local participants if one doesn't exist
if (!document.getElementById(`audio-${participantId}`) && !isLocal) {
this.createAudioElement(participantId);
}
Object.entries(tracks).forEach(([trackType, trackInfo]) => {
// If a persistentTrack exists...
if (trackInfo.persistentTrack) {
// Check if this is the local participant's audio track.
// If so, we will skip playing it, as it's already being played.
// We'll start or update tracks in all other cases.
if (!(isLocal && trackType === "audio")) {
this.startOrUpdateTrack(trackType, trackInfo, participantId);
}
} else {
// If the track is not available, remove the media element
this.destroyTracks([trackType], participantId);
}
// Update the video UI based on the track's state
if (trackType === "video") {
this.updateVideoUi(trackInfo, participantId);
}
// Update the camera and microphone states for the local user based on
// the track's state
if (isLocal) {
this.updateUiForDevicesState(trackType, trackInfo);
}
});
}
/**
* Updates the UI with the current active speaker's identity.
* @param {Object} event - The active speaker change event object.
*/
handleActiveSpeakerChange(event) {
document.getElementById(
"active-speaker"
).textContent = `Active Speaker: ${event.activeSpeaker.peerId}`;
}
/**
* Tries to join a call with provided room URL and optional join token.
* @param {string} roomUrl - The URL of the room to join.
* @param {string|null} joinToken - An optional token for joining the room.
*/
async joinRoom(roomUrl, joinToken = null) {
if (!roomUrl) {
console.error("Room URL is required to join a room.");
return;
}
this.currentRoomUrl = roomUrl;
const joinOptions = { url: roomUrl };
if (joinToken) {
joinOptions.token = joinToken;
console.log("Joining with a token.");
} else {
console.log("Joining without a token.");
}
try {
// Disable the join button to prevent multiple attempts to join
document.getElementById("join-btn").disabled = true;
// Join the room
await this.call.join(joinOptions);
} catch (e) {
console.error("Join failed:", e);
}
}
/**
* Creates and sets up a new video container for a specific participant. This
* function dynamically generates a video element along with a container and
* an overlay displaying the participant's ID. The newly created elements are
* appended to a designated parent in the DOM, preparing them for video
* streaming or playback related to the specified participant.
*
* @param {string} participantId - The unique identifier for the participant.
*/
createVideoContainer(participantId) {
// Create a video container for the participant
const videoContainer = document.createElement("div");
videoContainer.id = `video-container-${participantId}`;
videoContainer.className = "video-container";
document.getElementById("videos").appendChild(videoContainer);
// Add an overlay to display the participant's session ID
// const sessionIdOverlay = document.createElement("div");
// sessionIdOverlay.className = "session-id-overlay";
// sessionIdOverlay.textContent = participantId;
// videoContainer.appendChild(sessionIdOverlay);
// Create a video element for the participant
const videoEl = document.createElement("video");
videoEl.className = "video-element";
videoContainer.appendChild(videoEl);
}
/**
* Creates an audio element for a particular participant. This function is
* responsible for dynamically generating a standalone audio element that can
* be used to play audio streams associated with the specified participant.
* The audio element is appended directly to the document body or a relevant
* container, thereby preparing it for playback of the participant's audio.
*
* @param {string} participantId - A unique identifier corresponding to the participant.
*/
createAudioElement(participantId) {
// Create an audio element for the participant
const audioEl = document.createElement("audio");
audioEl.id = `audio-${participantId}`;
document.body.appendChild(audioEl);
}
/**
* Updates the media track (audio or video) source for a specific participant
* and plays the updated track. It checks if the source track needs to be
* updated and performs the update if necessary, ensuring playback of the
* media track.
*
* @param {string} trackType - Specifies the type of track to update ('audio'
* or 'video'), allowing the function to dynamically adapt to the track being
* processed.
* @param {Object} track - Contains the media track data, including the
* `persistentTrack` property which holds the actual MediaStreamTrack to be
* played or updated.
* @param {string} participantId - Identifies the participant whose media
* track is being updated.
*/
startOrUpdateTrack(trackType, track, participantId) {
// Construct the selector string or ID based on the trackType.
const selector =
trackType === "video"
? `#video-container-${participantId} video.video-element`
: `audio-${participantId}`;
// Retrieve the specific media element from the DOM.
const trackEl =
trackType === "video"
? document.querySelector(selector)
: document.getElementById(selector);
// Error handling if the target media element does not exist.
if (!trackEl) {
console.error(
`${trackType} element does not exist for participant: ${participantId}`
);
return;
}
// Check for the need to update the media source. This is determined by
// checking whether the existing srcObject's tracks include the new
// persistentTrack. If there are no existing tracks or the new track is not
// among them, an update is necessary.
const existingTracks = trackEl.srcObject?.getTracks();
const needsUpdate = !existingTracks?.includes(track.persistentTrack);
// Perform the media source update if needed by setting the srcObject of
// the target element to a new MediaStream containing the provided
// persistentTrack.
if (needsUpdate) {
trackEl.srcObject = new MediaStream([track.persistentTrack]);
// Once the media metadata is loaded, attempts to play the track. Error
// handling for play failures is included to catch and log issues such as
// autoplay policies blocking playback.
trackEl.onloadedmetadata = () => {
trackEl
.play()
.catch((e) =>
console.error(
`Error playing ${trackType} for participant ${participantId}:`,
e
)
);
};
}
}
/**
* Shows or hides the video element for a participant, including managing
* the visibility of the video based on the track state.
* @param {Object} track - The video track object.
* @param {string} participantId - The ID of the participant.
*/
updateVideoUi(track, participantId) {
let videoEl = document
.getElementById(`video-container-${participantId}`)
.querySelector("video.video-element");
switch (track.state) {
case "off":
case "interrupted":
case "blocked":
videoEl.style.display = "none"; // Hide video but keep container
break;
case "playable":
default:
// Here we handle all other states the same as we handle 'playable'.
// In your code, you may choose to handle them differently.
videoEl.style.display = "";
break;
}
}
/**
* Cleans up specified media track types (e.g., 'video', 'audio') for a given
* participant by stopping the tracks and removing their corresponding
* elements from the DOM. This is essential for properly managing resources
* when participants leave or change their track states.
* @param {Array} trackTypes - An array of track types to destroy, e.g.,
* ['video', 'audio'].
* @param {string} participantId - The ID of the participant.
*/
destroyTracks(trackTypes, participantId) {
trackTypes.forEach((trackType) => {
const elementId = `${trackType}-${participantId}`;
const element = document.getElementById(elementId);
if (element) {
element.srcObject = null; // Release media resources
element.parentNode.removeChild(element); // Remove element from the DOM
}
});
}
/**
* Toggles the local video track's mute state.
*/
toggleCamera() {
this.call.setLocalVideo(!this.call.localVideo());
}
/**
* Toggles the local audio track's mute state.
*/
toggleMicrophone() {
this.call.setLocalAudio(!this.call.localAudio());
}
/**
* Updates the UI to reflect the current states of the local participant's
* camera and microphone.
* @param {string} trackType - The type of track, either 'video' for cameras
* or 'audio' for microphones.
* @param {Object} trackInfo - The track object.
*/
updateUiForDevicesState(trackType, trackInfo) {
// For video, set the camera state
if (trackType === "video") {
document.getElementById("camera-state").textContent = `Camera: ${
this.call.localVideo() ? "On" : "Off"
}`;
} else if (trackType === "audio") {
// For audio, set the mic state
document.getElementById("mic-state").textContent = `Mic: ${
this.call.localAudio() ? "On" : "Off"
}`;
}
}
/**
* Sets up device selectors for cameras and microphones by dynamically
* populating them with available devices and attaching event listeners to
* handle device selection changes.
*/
async setupDeviceSelectors() {
// Fetch current input devices settings and an array of available devices.
const selectedDevices = await this.call.getInputDevices();
const { devices: allDevices } = await this.call.enumerateDevices();
// Element references for camera and microphone selectors.
const selectors = {
videoinput: document.getElementById("camera-selector"),
audioinput: document.getElementById("mic-selector"),
};
// Prepare selectors by clearing existing options and adding a
// non-selectable prompt.
Object.values(selectors).forEach((selector) => {
selector.innerHTML = "";
const promptOption = new Option(
`Select a ${selector.id.includes("camera") ? "camera" : "microphone"}`,
"",
true,
true
);
promptOption.disabled = true;
selector.appendChild(promptOption);
});
// Create and append options to the selectors based on available devices.
allDevices.forEach((device) => {
if (device.label && selectors[device.kind]) {
const isSelected =
selectedDevices[device.kind === "videoinput" ? "camera" : "mic"]
.deviceId === device.deviceId;
const option = new Option(
device.label,
device.deviceId,
isSelected,
isSelected
);
selectors[device.kind].appendChild(option);
}
});
// Listen for user device change requests.
Object.entries(selectors).forEach(([deviceKind, selector]) => {
selector.addEventListener("change", async (e) => {
const deviceId = e.target.value;
const deviceOptions = {
[deviceKind === "videoinput" ? "videoDeviceId" : "audioDeviceId"]:
deviceId,
};
await this.call.setInputDevicesAsync(deviceOptions);
});
});
}
/**
* Updates the UI with the current number of participants.
* This method combines getting the participant count and updating the UI.
*/
updateAndDisplayParticipantCount() {
const participantCount =
this.call.participantCounts().present +
this.call.participantCounts().hidden;
document.getElementById(
"participant-count"
).textContent = `Participants: ${participantCount}`;
}
/**
* Leaves the call and performs necessary cleanup operations like removing
* video elements.
*/
async leave() {
try {
await this.call.leave();
document.querySelectorAll("#videos video, audio").forEach((el) => {
el.srcObject = null; // Release media resources
el.remove(); // Remove the element from the DOM
});
} catch (e) {
console.error("Leaving failed", e);
}
}
}
/**
* Main entry point: Setup and event listener bindings after the DOM is fully
* loaded.
*/ /**
* Main entry point: Setup and event listener bindings after the DOM is fully loaded.
*/
document.addEventListener("DOMContentLoaded", async () => {
const dailyCallManager = new DailyCallManager();
// Extract the room URL from query parameters if available
const urlParams = new URLSearchParams(window.location.search);
const roomUrlParam = urlParams.get("room_url");
if (roomUrlParam) {
document.getElementById("room-url").value = roomUrlParam.trim();
}
// Bind the join call action to the join button.
document
.getElementById("join-btn")
.addEventListener("click", async function () {
const roomUrl = document.getElementById("room-url").value.trim();
const joinToken =
document.getElementById("join-token").value.trim() || null;
await dailyCallManager.joinRoom(roomUrl, joinToken);
});
// Bind the leave call action to the leave button.
document.getElementById("leave-btn").addEventListener("click", function () {
dailyCallManager.leave();
});
});