/**
* CloudflareCalls.js
*
* High-level library for Cloudflare Calls using SFU,
* now leveraging WebSocket for data message publish/subscribe flow.
*/
/**
* Represents the CloudflareCalls library for managing real-time communications.
*/
class CloudflareCalls {
/**
* @typedef {Object} VideoQualitySettings
* @property {Object} width - Video width settings
* @property {number} width.ideal - Ideal video width in pixels
* @property {Object} height - Video height settings
* @property {number} height.ideal - Ideal video height in pixels
* @property {Object} frameRate - Frame rate settings
* @property {number} frameRate.ideal - Ideal frame rate in fps
* @property {number} maxBitrate - Maximum video bitrate in bps
*/
/**
* @typedef {Object} AudioQualitySettings
* @property {number} maxBitrate - Maximum audio bitrate in bps
* @property {number} sampleRate - Audio sample rate in Hz
* @property {number} channelCount - Number of audio channels (1=mono, 2=stereo)
*/
/**
* @typedef {Object} QualityPreset
* @property {VideoQualitySettings} video - Video quality settings
* @property {AudioQualitySettings} audio - Audio quality settings
*/
/**
* @typedef {Object} ConnectionStats
* @property {Object} outbound - Outbound (sending) statistics
* @property {number} outbound.bitrate - Current outbound bitrate in bits/s
* @property {number} outbound.packetLoss - Percentage of packets lost
* @property {string} outbound.qualityLimitation - Reason for quality limitations (if any)
* @property {Object} inbound - Inbound (receiving) statistics per track
* @property {number} inbound.bitrate - Current inbound bitrate in bits/s
* @property {number} inbound.packetLoss - Percentage of packets lost
* @property {number} inbound.jitter - Current jitter in seconds
* @property {Object} connection - Overall connection statistics
* @property {number} connection.roundTripTime - Current round trip time in seconds
* @property {string} connection.state - Current connection state
*/
/**
* @typedef {Object} StreamStats
* @property {string} sessionId - Session ID of the stream
* @property {number} packetLoss - Packet loss percentage
* @property {string} qualityLimitation - Quality limitation reason
* @property {number} bitrate - Current bitrate
*/
/**
* Creates an instance of CloudflareCalls.
* @param {Object} config - Configuration object.
* @param {string} config.backendUrl - The backend server URL.
* @param {string} config.websocketUrl - The WebSocket server URL.
*/
constructor(config = {}) {
this.backendUrl = config.backendUrl || '';
this.websocketUrl = config.websocketUrl || '';
this.debug = config.debug || false;
this.token = null;
this.roomId = null;
this.sessionId = null;
this.userId = this._generateUUID();
this.userMetadata = {};
this.localStream = null;
this.peerConnection = null;
this.ws = null;
// Specific message handlers
this._onParticipantJoinedCallback = null;
this._onParticipantLeftCallback = null;
this._onRemoteTrackCallback = null;
this._onRemoteTrackUnpublishedCallback = null;
this._onTrackStatusChangedCallback = null;
this._onDataMessageCallback = null;
this._onConnectionStatsCallback = null;
// Generic message handlers
this._wsMessageHandlers = new Set();
// Track management
this.pulledTracks = new Map(); // Map<sessionId, Set<trackName>>
this.pollingInterval = null; // Reference to the polling interval
// Device management
this.availableAudioInputDevices = [];
this.availableVideoInputDevices = [];
this.availableAudioOutputDevices = [];
this.currentAudioOutputDeviceId = null;
this._renegotiateTimeout = null;
this.publishedTracks = new Set();
this.midToSessionId = new Map();
this.midToTrackName = new Map();
this._onRoomMetadataUpdatedCallback = null;
// Store initial quality settings
/** @type {QualityPreset} */
this.pendingQualitySettings = null;
this.mediaQuality = CloudflareCalls.QUALITY_PRESETS.medium_16x9_md;
/** @type {Object.<string, QualityPreset>} */
this.QUALITY_PRESETS = CloudflareCalls.QUALITY_PRESETS;
// Stats monitoring
this.statsInterval = null;
this.previousStats = null;
/** @type {'stopped'|'monitoring'} */
this.statsMonitoringState = 'stopped';
}
/**
* Internal logging method that only outputs when debug is enabled
* @private
* @param {...any} args - Arguments to pass to console.log
*/
_log(...args) {
if (this.debug) {
console.log('[CloudflareCalls]', ...args);
}
}
/**
* Internal warning method that only outputs when debug is enabled
* @private
* @param {...any} args - Arguments to pass to console.warn
*/
_warn(...args) {
if (this.debug) {
console.warn('[CloudflareCalls]', ...args);
}
}
/**
* Internal error method that always outputs (important for debugging)
* @private
* @param {...any} args - Arguments to pass to console.error
*/
_error(...args) {
console.error('[CloudflareCalls]', ...args);
}
/**
* Enable or disable debug logging
* @param {boolean} enabled - Whether to enable debug logging
*/
setDebugMode(enabled) {
this.debug = Boolean(enabled);
}
/**
* Internal method to perform fetch requests with automatic token inclusion and JSON parsing.
* @private
* @param {string} url - The full URL to fetch.
* @param {Object} options - Fetch options such as method, headers, body, etc.
* @returns {Promise<Object>} The parsed JSON response.
* @throws {Error} If the response is not OK.
*/
async _fetch(url, options = {}) {
// Initialize headers if not provided
options.headers = options.headers || {};
// Add Authorization header if token is set
if (this.token) {
options.headers['Authorization'] = `Bearer ${this.token}`;
}
try {
const response = await fetch(url, options);
// Check if the response status is OK (status in the range 200-299)
if (!response.ok) {
this._warn(`HTTP error! status: ${response.status}`);
}
return response;
} catch (error) {
this._warn(`Fetch error for ${url}:`, error);
return false;
}
}
/************************************************
* Callback Registration
***********************************************/
/**
* Registers a callback for remote track events.
* @param {Function} callback - The callback function to handle remote tracks.
*/
onRemoteTrack(callback) {
this._onRemoteTrackCallback = callback;
}
/**
* Registers a callback for remote track unpublished events.
* @param {Function} callback - The callback function to handle track unpublished events.
*/
onRemoteTrackUnpublished(callback) {
this._onRemoteTrackUnpublishedCallback = callback;
}
/**
* Registers a callback for incoming data messages.
* @param {Function} callback - The callback function to handle data messages.
*/
onDataMessage(callback) {
this._onDataMessageCallback = callback;
}
/**
* Registers a callback for participant joined events.
* @param {Function} callback - The callback function to handle participant joins.
*/
onParticipantJoined(callback) {
this._onParticipantJoinedCallback = callback;
}
/**
* Registers a callback for participant left events.
* @param {Function} callback - The callback function to handle participant departures.
*/
onParticipantLeft(callback) {
this._onParticipantLeftCallback = callback;
}
/**
* Registers a callback for track status changed events.
* @param {Function} callback - The callback function to handle track status changes.
*/
onTrackStatusChanged(callback) {
this._onTrackStatusChangedCallback = callback;
}
/**
* Registers a callback for WebSocket messages
* @param {Function} callback - Function to call when WebSocket messages are received
* @returns {Function} Function to unregister the callback
*/
onWebSocketMessage(callback) {
this._wsMessageHandlers.add(callback);
return () => this._wsMessageHandlers.delete(callback);
}
/************************************************
* User Metadata Management
***********************************************/
/**
* Sets the user token for server requests. This should be a JWT token, and will be delivered in Authorization headers (HTTP) and to authenticate websocket join requests.
* @param {String} token - The metadata to associate with the user.
*/
setToken(token) {
this.token = token;
}
/**
* Register callback for room metadata updates
* @param {Function} callback Callback function
*/
onRoomMetadataUpdated(callback) {
this._onRoomMetadataUpdatedCallback = callback;
}
/**
* Sets the user metadata and updates it on the server.
* @param {Object} metadata - The metadata to associate with the user.
*/
setUserMetadata(metadata) {
this.userMetadata = metadata;
this._updateUserMetadataOnServer();
}
/**
* Retrieves the current user metadata.
* @returns {Object} The user metadata.
*/
getUserMetadata() {
return this.userMetadata;
}
/**
* Updates user metadata on the server
* @private
* @async
* @returns {Promise<void>}
*/
async _updateUserMetadataOnServer() {
if (!this.roomId || !this.sessionId) {
this._warn('Cannot update metadata before joining a room.');
return;
}
try {
const updateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/metadata`;
const response = await this._fetch(updateUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.userMetadata)
});
if (!response.ok) {
this._error('Failed to update user metadata on server.');
} else {
this._log('User metadata updated on server.');
}
} catch (error) {
this._error('Error updating user metadata:', error);
throw error;
}
}
/************************************************
* Room & Session Management
***********************************************/
/**
* Creates a new room with optional metadata.
* @async
* @param {Object} options Room creation options
* @param {string} [options.name] Room name
* @param {Object} [options.metadata] Room metadata
* @returns {Promise<Object>} Created room information including roomId, name, metadata, etc.
*/
async createRoom(options = {}) {
const resp = await this._fetch(`${this.backendUrl}/api/rooms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options)
}).then(r => r.json());
// Store the roomId
this.roomId = resp.roomId;
// Return the full room object
return resp;
}
/**
* Joins an existing room.
* @async
* @param {string} roomId - The ID of the room to join.
* @param {Object} [metadata={}] - Optional metadata for the user.
* @returns {Promise<void>}
*/
async joinRoom(roomId, metadata = {}) {
this.roomId = roomId;
// 1) Ask server to create a CF Calls session
const joinResp = await this._fetch(`${this.backendUrl}/api/rooms/${roomId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: this.userId, metadata: this.userMetadata })
}).then(r => r.json());
await this._initWebSocket();
if (!joinResp.sessionId) {
throw new Error('Failed to join room or retrieve sessionId');
}
this.sessionId = joinResp.sessionId;
// Initialize pulledTracks map
this.pulledTracks.set(this.sessionId, new Set());
// 2) Create RTCPeerConnection
this.peerConnection = await this._createPeerConnection();
// 3) Get Local Media and Publish Tracks
if (!this.localStream) {
this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
this._log('Acquired local media');
}
await this._publishTracks();
// 4) Pull other participants' tracks
const otherSessions = joinResp.otherSessions || [];
for (const s of otherSessions) {
this.pulledTracks.set(s.sessionId, new Set());
for (const tName of s.publishedTracks || []) {
await this._pullTracks(s.sessionId, tName);
}
}
this._log('Joined room', roomId, 'my session:', this.sessionId);
this.setUserMetadata(metadata);
// 5) Start polling for new tracks
this._startPolling();
}
/**
* Cleans up ended tracks in localStream
* @async
* @private
* @returns {void}
*/
async _cleanupEndedTracks() {
// Clear local media devices (readyState == 'ended', so they can't be reused)
if (this.localStream) {
for (const track of this.localStream.getTracks()) {
if (track.readyState === 'ended') {
this.localStream.removeTrack(track);
track.stop();
}
}
}
// If no tracks remain, clear the stream
if (this.localStream && !this.localStream.getTracks().length) {
this.localStream = null;
}
}
/**
* Leaves the current room and cleans up connections.
* @async
* @returns {Promise<void>}
*/
async leaveRoom() {
if (!this.roomId || !this.sessionId) return;
// Clean up published tracks (if applicable)
const senders = this.peerConnection.getSenders();
if (senders && senders.length) {
await this.unpublishAllTracks();
}
try {
await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/leave`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: this.sessionId })
});
} catch (error) {
this._warn('Error leaving room:', error);
}
// Clean up WebSocket
if (this.ws) {
this.ws.close();
this.ws = null;
}
// Clean up PeerConnection
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
await this._cleanupEndedTracks();
this._log('Left room, closed PC & WS');
// Reset room state
this.roomId = null;
this.sessionId = null;
this.pulledTracks.clear();
this.midToSessionId.clear();
this.midToTrackName.clear();
this.publishedTracks.clear();
}
/************************************************
* Publish & Pull
***********************************************/
/**
* Publishes the local media tracks to the room.
* @async
* @returns {Promise<void>}
* @throws {Error} If there is no local media stream to publish.
*/
async publishTracks() {
if (!this.localStream) {
return this._warn('No local media stream to publish.');
}
await this._publishTracks();
}
// /**
// * Unpublishes a specific local media track (audio or video).
// * @async
// * @param {string} trackKind - The kind of track to unpublish ('audio' or 'video').
// * @param {boolean} [force=false] - If true, forces track closure without renegotiation.
// * @returns {Promise<Object>} Result object from the Cloudflare API.
// * @throws {Error} If PeerConnection is not established or track is not found.
// */
// // Todo: I don't think this method works
// async unpublishTrack(trackKind, force = false) {
// if (!this.peerConnection) {
// return this._warn('PeerConnection is not established.');
// }
//
// const sender = this.peerConnection.getSenders().find(s => s.track?.kind === trackKind);
// if (!sender) {
// return this._warn(`No ${trackKind} track found to unpublish.`);
// }
//
// const transceiver = this.peerConnection.getTransceivers().find(t => t.sender === sender);
// if (!transceiver?.mid) {
// throw new Error('Could not find transceiver mid for track');
// }
//
// try {
// // Create an offer for the updated state
// const offer = await this.peerConnection.createOffer();
// await this.peerConnection.setLocalDescription(offer);
//
// const unpublishUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/unpublish`;
// const response = await this._fetch(unpublishUrl, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// trackName: sender.track.id,
// mid: transceiver.mid,
// force,
// sessionDescription: {
// type: offer.type,
// sdp: offer.sdp
// }
// })
// });
//
// if (!response || !response.ok) return false;
// const result = await response.json();
//
// // Stop the track
// sender.track.stop();
//
// // Remove from PeerConnection after server confirms
// this.peerConnection.removeTrack(sender);
//
// // Remove from our tracked set
// this.publishedTracks.delete(sender.track.id);
//
// return result;
// } catch (error) {
// this._warn(`Error unpublishing ${trackKind} track:`, error);
// return false;
// }
// }
/**
* Initiates renegotiation of the PeerConnection.
* @async
* @private
* @returns {Promise<void>}
*/
async _renegotiate() {
if (!this.peerConnection) return;
if (this._renegotiateTimeout) {
clearTimeout(this._renegotiateTimeout);
}
this._renegotiateTimeout = setTimeout(async () => {
try {
this._log('Starting renegotiation process...');
const answer = await this.peerConnection.createAnswer();
this._log('Created renegotiation answer:', answer.sdp);
await this.peerConnection.setLocalDescription(answer);
const renegotiateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`;
const body = { sdp: answer.sdp, type: answer.type };
this._log(`Sending renegotiate request to ${renegotiateUrl} with body:`, body);
const response = await this._fetch(renegotiateUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(r => r.json());
if (response.errorCode) {
this._warn('Renegotiation failed:', response.errorDescription);
return;
}
await this.peerConnection.setRemoteDescription(response.sessionDescription);
this._log('Renegotiation successful. Applied SFU response.');
} catch (error) {
this._error('Error during renegotiation:', error);
}
}, 500);
}
/**
* Updates the published media tracks.
* @async
* @returns {Promise<void>}
* @throws {Error} If the PeerConnection is not established.
*/
// Todo: I don't know what this was supposed to accomplish
// Possibly unpublish and re-publish tracks to solve some lifecycle issue
async updatePublishedTracks() {
if (!this.peerConnection) {
return this._warn('PeerConnection is not established.');
}
// Remove existing senders
const senders = this.peerConnection.getSenders();
for (const sender of senders) {
this.peerConnection.removeTrack(sender);
}
// Add updated tracks
await this._publishTracks();
}
/**
* Publishes the local media tracks to the PeerConnection and server.
* @async
* @private
* @returns {Promise<void>}
*/
async _publishTracks() {
if (!this.localStream || !this.peerConnection) return;
const transceivers = [];
for (const track of this.localStream.getTracks()) {
// Check if we've already published this track
if (this.publishedTracks.has(track.id)) continue;
if (track.readyState !== 'live') continue;
const tx = this.peerConnection.addTransceiver(track, { direction: 'sendonly' });
// Apply any pending quality settings to video tracks
if (this.pendingQualitySettings && track.kind === 'video') {
const params = tx.sender.getParameters();
params.encodings = [{
maxBitrate: this.pendingQualitySettings.video.maxBitrate
}];
tx.sender.setParameters(params);
}
transceivers.push(tx);
this.publishedTracks.add(track.id);
}
if (transceivers.length === 0) return; // No new tracks to publish
const offer = await this.peerConnection.createOffer();
this._log('SDP Offer:', offer.sdp);
await this.peerConnection.setLocalDescription(offer);
const trackInfos = transceivers.map(({ sender, mid }) => ({
location: 'local',
mid,
trackName: sender.track.id
}));
const body = {
offer: { sdp: offer.sdp, type: offer.type },
tracks: trackInfos,
metadata: this.userMetadata
};
const publishUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/publish`;
const resp = await this._fetch(publishUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(r => r.json());
if (resp.errorCode) {
this._error('Publish error:', resp.errorDescription);
return;
}
// The SFU's answer
const answer = resp.sessionDescription;
await this.peerConnection.setRemoteDescription(answer);
this._log('Publish => success. Applied SFU answer.');
}
/**
* Pulls a specific track from a remote session.
* @async
* @private
* @param {string} remoteSessionId - The session ID of the remote participant.
* @param {string} trackName - The name of the track to pull.
* @returns {Promise<void>}
*/
async _pullTracks(remoteSessionId, trackName) {
this._log(`Pulling track '${trackName}' from session ${remoteSessionId}`);
const pullUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/pull`;
const body = { remoteSessionId, trackName };
const resp = await this._fetch(pullUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(r => r.json());
if (resp.errorCode) {
this._error('Pull error:', resp.errorDescription);
return;
}
if (resp.requiresImmediateRenegotiation) {
this._log('Pull => requires renegotiation');
// Set up both mappings from the SDP
const pendingMids = new Set();
resp.sessionDescription.sdp.split('\n').forEach(line => {
if (line.startsWith('a=mid:')) {
const mid = line.split(':')[1].trim();
pendingMids.add(mid);
this.midToSessionId.set(mid, remoteSessionId);
this.midToTrackName.set(mid, trackName);
this._log('Pre-mapped MID:', {
mid,
sessionId: remoteSessionId,
trackName
});
}
});
// Now set the remote description
await this.peerConnection.setRemoteDescription(resp.sessionDescription);
// Create and set local answer
const localAnswer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(localAnswer);
// Verify mappings are still correct
const transceivers = this.peerConnection.getTransceivers();
transceivers.forEach(transceiver => {
if (transceiver.mid && pendingMids.has(transceiver.mid)) {
this._log('Verified MID mapping:', {
mid: transceiver.mid,
sessionId: remoteSessionId,
direction: transceiver.direction
});
}
});
await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sdp: localAnswer.sdp, type: localAnswer.type })
});
}
this._log(`Pulled trackName="${trackName}" from session ${remoteSessionId}`);
this._log('Current MID mappings:', Array.from(this.midToSessionId.entries()));
// Record the pulled track
if (!this.pulledTracks.has(remoteSessionId)) {
this.pulledTracks.set(remoteSessionId, new Set());
}
this.pulledTracks.get(remoteSessionId).add(trackName);
}
/************************************************
* PeerConnection & WebSocket
***********************************************/
/**
* Creates and configures a new RTCPeerConnection.
* @async
* @private
* @returns {Promise<RTCPeerConnection>} The configured RTCPeerConnection instance.
*/
async _attemptIceServersUpdate() {
let iceServers = [{ urls: 'stun:stun.cloudflare.com:3478' }];
try {
const response = await this._fetch(`${this.backendUrl}/api/ice-servers`);
if (!response.ok) {
this._warn(`Failed to fetch ICE servers: ${response.status} ${response.statusText}`);
return false;
}
const data = await response.json();
// Validate and process the fetched ICE servers
if (data.iceServers && Array.isArray(data.iceServers)) {
iceServers = data.iceServers.map(server => {
// Ensure each server has the required fields
const iceServer = { urls: server.urls };
if (server.username && server.credential) {
iceServer.username = server.username;
iceServer.credential = server.credential;
}
return iceServer;
});
this._log('Fetched ICE servers:', iceServers);
} else {
return iceServers;
}
} catch (error) {
this._warn('Error fetching ICE servers:', error);
// Fallback to default ICE servers if fetching fails
return false;
}
}
async _createPeerConnection() {
let iceServers = await this._attemptIceServersUpdate() || [{ urls: 'stun:stun.cloudflare.com:3478' }];
const pc = new RTCPeerConnection({
iceServers: iceServers,
bundlePolicy: 'max-bundle',
sdpSemantics: 'unified-plan'
});
pc.onicecandidate = (evt) => {
if (evt.candidate) {
this._log('New ICE candidate:', evt.candidate.candidate);
} else {
this._log('All ICE candidates have been sent');
}
};
pc.oniceconnectionstatechange = () => {
this._log('ICE Connection State:', pc.iceConnectionState);
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
this.leaveRoom();
}
};
pc.onconnectionstatechange = () => {
this._log('Connection State:', pc.connectionState);
if (pc.connectionState === 'connected') {
this._log('Peer connection fully established');
} else if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
this._log('Peer connection disconnected or failed');
this.leaveRoom();
}
};
pc.ontrack = (evt) => {
this._log('ontrack event:', {
kind: evt.track.kind,
webrtcTrackId: evt.track.id,
mid: evt.transceiver?.mid
});
if (this._onRemoteTrackCallback) {
const mid = evt.transceiver?.mid;
const sessionId = this.midToSessionId.get(mid);
const trackName = this.midToTrackName.get(mid);
this._log('Track mapping lookup:', {
mid,
sessionId,
trackName,
webrtcTrackId: evt.track.id,
availableMappings: {
sessions: Array.from(this.midToSessionId.entries()),
tracks: Array.from(this.midToTrackName.entries())
}
});
if (!sessionId) {
this._warn('No sessionId found for mid:', mid);
if (!this.pendingTracks) this.pendingTracks = [];
this.pendingTracks.push({ evt, mid });
return;
}
const wrappedTrack = evt.track;
wrappedTrack.sessionId = sessionId;
wrappedTrack.mid = mid;
wrappedTrack.trackName = trackName;
this._log('Sending track to callback:', {
webrtcTrackId: wrappedTrack.id,
trackName: wrappedTrack.trackName,
sessionId: wrappedTrack.sessionId,
mid: wrappedTrack.mid
});
this._onRemoteTrackCallback(wrappedTrack);
}
};
return pc;
}
/**
* Initializes the WebSocket connection.
* @async
* @private
* @returns {Promise<void>}
*/
async _initWebSocket() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.websocketUrl);
this.ws.onopen = () => {
this._log('WebSocket open');
this.ws.send(JSON.stringify({
type: 'join-websocket',
payload: {
roomId: this.roomId,
userId: this.userId,
token: this.token
}
}));
resolve();
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this._log('WebSocket message received:', message);
// Handle specific message types
switch (message.type) {
case 'participant-joined':
if (this._onParticipantJoinedCallback) {
this._onParticipantJoinedCallback(message.payload);
}
break;
case 'participant-left':
if (this._onParticipantLeftCallback) {
this._onParticipantLeftCallback(message.payload);
}
break;
case 'track-published':
if (this._onRemoteTrackCallback) {
// Handle track published event
this._onRemoteTrackCallback(message.payload);
}
break;
case 'track-unpublished':
if (this._onRemoteTrackUnpublishedCallback) {
this._onRemoteTrackUnpublishedCallback(
message.payload.sessionId,
message.payload.trackName
);
}
break;
case 'track-status-changed':
if (this._onTrackStatusChangedCallback) {
this._onTrackStatusChangedCallback(message.payload);
}
break;
case 'data-message':
if (this._onDataMessageCallback) {
this._onDataMessageCallback(message.payload);
}
break;
case 'room-metadata-updated':
if (this._onRoomMetadataUpdatedCallback) {
this._onRoomMetadataUpdatedCallback(message.payload);
}
break;
default:
this._log('Unhandled message type:', message.type);
}
// Notify generic handlers
this._wsMessageHandlers.forEach(handler => handler(message));
} catch (error) {
this._error('Error processing WebSocket message:', error);
}
};
this.ws.onerror = (err) => {
this._error('WebSocket error:', err);
reject(err);
};
this.ws.onclose = () => {
this._log('WebSocket connection closed');
};
});
}
/************************************************
* Polling for New Tracks
***********************************************/
/**
* Starts polling the server for new tracks every 10 seconds.
* @private
* @returns {void}
*/
_startPolling() {
this.pollingInterval = setInterval(async () => {
if (!this.roomId) return;
try {
const resp = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`)
.then(r => r.json());
const participants = resp.participants || [];
for (const participant of participants) {
const { sessionId, publishedTracks } = participant;
if (sessionId === this.sessionId) continue; // Skip self
if (!this.pulledTracks.has(sessionId)) {
this.pulledTracks.set(sessionId, new Set());
}
for (const trackName of publishedTracks) {
if (!this.pulledTracks.get(sessionId).has(trackName)) {
this._log(`[Polling] New track detected: ${trackName} from session ${sessionId}`);
await this._pullTracks(sessionId, trackName);
}
}
}
} catch (err) {
this._error('Polling error:', err);
}
}, 10000);
}
/************************************************
* Device Management
***********************************************/
/**
* Retrieves the list of available media devices.
* @async
* @returns {Promise<Object>} An object containing arrays of audio input, video input, and audio output devices.
*/
async getAvailableDevices() {
const devices = await navigator.mediaDevices.enumerateDevices();
this.availableAudioInputDevices = devices.filter(device => device.kind === 'audioinput');
this.availableVideoInputDevices = devices.filter(device => device.kind === 'videoinput');
this.availableAudioOutputDevices = devices.filter(device => device.kind === 'audiooutput');
return {
audioInput: this.availableAudioInputDevices,
videoInput: this.availableVideoInputDevices,
audioOutput: this.availableAudioOutputDevices
};
}
/**
* Selects a specific audio input device.
* @async
* @param {string} deviceId - The ID of the audio input device to select.
* @returns {Promise<void>}
*/
async selectAudioInputDevice(deviceId) {
if (!deviceId) {
this._warn('No deviceId provided for audio input.');
return;
}
const constraints = {
audio: { deviceId: { exact: deviceId } },
video: false
};
try {
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
const newAudioTrack = newStream.getAudioTracks()[0];
const sender = this.peerConnection.getSenders().find(s => s.track.kind === 'audio');
if (sender) {
sender.replaceTrack(newAudioTrack);
const oldTrack = sender.track;
oldTrack.stop();
} else {
this.localStream.addTrack(newAudioTrack);
await this._publishTracks();
}
this._log(`Switched to audio input device: ${deviceId}`);
} catch (error) {
this._error('Error switching audio input device:', error);
}
}
/**
* Selects a specific video input device.
* @async
* @param {string} deviceId - The ID of the video input device to select.
* @returns {Promise<void>}
*/
async selectVideoInputDevice(deviceId) {
if (!deviceId) {
this._warn('No deviceId provided for video input.');
return;
}
const constraints = {
video: { deviceId: { exact: deviceId } },
audio: false
};
try {
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
const newVideoTrack = newStream.getVideoTracks()[0];
const sender = this.peerConnection.getSenders().find(s => s.track.kind === 'video');
if (sender) {
sender.replaceTrack(newVideoTrack);
const oldTrack = sender.track;
oldTrack.stop();
} else {
this.localStream.addTrack(newVideoTrack);
await this._publishTracks();
}
this._log(`Switched to video input device: ${deviceId}`);
} catch (error) {
this._error('Error switching video input device:', error);
}
}
/**
* Selects a specific audio output device.
* @async
* @param {string} deviceId - The ID of the audio output device to select.
* @returns {Promise<void>}
*/
async selectAudioOutputDevice(deviceId) {
if (!deviceId) {
this._warn('No deviceId provided for audio output.');
return;
}
try {
const audioElements = document.querySelectorAll('audio');
for (const audio of audioElements) {
await audio.setSinkId(deviceId);
}
this.currentAudioOutputDeviceId = deviceId;
this._log(`Switched to audio output device: ${deviceId}`);
} catch (error) {
this._error('Error switching audio output device:', error);
}
}
/**
* Previews media streams with specified device IDs.
* @async
* @param {Object} params - Parameters for media preview.
* @param {string} [params.audioDeviceId] - The ID of the audio input device to use.
* @param {string} [params.videoDeviceId] - The ID of the video input device to use.
* @param {HTMLMediaElement} [previewElement=null] - The media element to display the preview.
* @returns {Promise<MediaStream>} The media stream being previewed.
* @throws {Error} If there is an issue accessing the media devices.
*/
async previewMedia({ audioDeviceId, videoDeviceId }, previewElement = null) {
const constraints = {
audio: audioDeviceId ? { deviceId: { exact: audioDeviceId } } : false,
video: videoDeviceId ? { deviceId: { exact: videoDeviceId } } : false
};
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
if (previewElement) {
previewElement.srcObject = stream;
}
return stream;
} catch (error) {
this._error('Error previewing media:', error);
throw error;
}
}
/************************************************
* Media Controls
***********************************************/
/**
* Toggles the enabled state of video and/or audio tracks.
* @param {Object} options - Options to toggle media tracks.
* @param {boolean} [options.video=null] - Whether to toggle video tracks.
* @param {boolean} [options.audio=null] - Whether to toggle audio tracks.
* @returns {void}
*/
toggleMedia({ video = null, audio = null }) {
if (!this.localStream) return;
if (video !== null) {
const videoTracks = this.localStream.getVideoTracks();
videoTracks.forEach(track => {
track.enabled = video;
// Find the corresponding sender and update the track status
const sender = this.peerConnection?.getSenders().find(s => s.track === track);
if (sender) {
// Send track status update to SFU
this._updateTrackStatus(sender.track.id, 'video', video);
}
});
}
if (audio !== null) {
const audioTracks = this.localStream.getAudioTracks();
audioTracks.forEach(track => {
track.enabled = audio;
// Find the corresponding sender and update the track status
const sender = this.peerConnection?.getSenders().find(s => s.track === track);
if (sender) {
// Send track status update to SFU
this._updateTrackStatus(sender.track.id, 'audio', audio);
}
});
}
}
/**
* Starts screen sharing.
* @async
* @returns {Promise<void>}
*/
async shareScreen() {
try {
// Stop any existing video tracks (Todo: breaks the addTrack)
await this.unpublishAllTracks('video');
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false // Most browsers don't support screen audio yet
});
const screenTrack = screenStream.getVideoTracks()[0];
// Add the new screen track
this.localStream.addTrack(screenTrack);
// Publish the new track
await this._publishTracks();
// Handle the user stopping screen share
screenTrack.onended = async () => {
await this.unpublishAllTracks();
await this._cleanupEndedTracks();
this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
this._log('Re-acquired local media');
await this._publishTracks();
};
} catch (err) {
this._error('Error sharing screen:', err);
throw err;
}
}
/************************************************
* WebSocket-Based Data Communication
***********************************************/
/**
* Internal method to send a message via WebSocket.
* @private
* @param {Object} data - The data object to send.
* @returns {void}
*/
_sendWebSocketMessage(data) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this._warn('WebSocket is not open. Cannot send message.');
return;
}
this.ws.send(JSON.stringify(data));
this._log('Sent WebSocket message:', data);
}
/************************************************
* Participant Management
***********************************************/
/**
* Lists all participants currently in the room.
* @async
* @returns {Promise<Array<Object>>} An array of participant objects.
* @throws {Error} If not connected to any room.
*/
async listParticipants() {
if (!this.roomId) {
return this._warn('Not connected to any room.');
}
const resp = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`)
.then(r => r.json());
return resp.participants || [];
}
/************************************************
* Helpers & Placeholders
***********************************************/
/**
* Generates a simple UUID.
* @private
* @returns {string} A generated UUID string.
*/
_generateUUID() {
// Simple placeholder generator
return 'xxxx-xxxx-xxxx-xxxx'.replace(/[x]/g, () =>
((Math.random() * 16) | 0).toString(16)
);
}
/**
* Unpublishes all currently published tracks (with filters for type)
* @async
* @param {string} trackKind - The kind of track to unpublish ('audio' or 'video').
* @param {boolean} [force=false] - If true, forces track closure without renegotiation.
* @returns {Promise<void>}
*/
async unpublishAllTracks(trackKind, force = false) {
if (!this.peerConnection) {
this._warn('PeerConnection is not established.');
return;
}
let senders = this.peerConnection.getSenders();
if (trackKind) {
senders = senders.filter(s => s.track && s.track.kind === trackKind);
}
this._log('Unpublishing all tracks:', senders.length);
// Create an offer for the updated state
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
for (const sender of senders) {
if (sender.track) {
try {
const trackId = sender.track.id;
const transceiver = this.peerConnection.getTransceivers().find(t => t.sender === sender);
const mid = transceiver ? transceiver.mid : null;
this._log('Unpublishing track:', { trackId, mid });
if (!mid) {
this._warn('No mid found for track:', trackId);
continue;
}
// Stop the track first
sender.track.stop();
// Notify server
await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/unpublish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trackName: trackId,
mid: mid,
force,
sessionDescription: {
type: offer.type,
sdp: offer.sdp
}
})
});
// Remove from PeerConnection after server confirms
this.peerConnection.removeTrack(sender);
// Remove from our tracked set
this.publishedTracks.delete(trackId);
// Since we're unpublishing we need to stop local streams
await this._cleanupEndedTracks();
this._log(`Successfully unpublished track: ${trackId}`);
} catch (error) {
this._error(`Error unpublishing track:`, error);
}
}
}
}
/**
* Gets the session state
* @async
* @returns {Promise<Object>} The session state
*/
async getSessionState() {
if (!this.sessionId) {
return this._warn('No active session');
}
try {
const response = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/state`);
const state = await response.json();
// Store track states internally
if (state.tracks) {
this.trackStates = new Map(
state.tracks.map(track => [track.trackName, track.status])
);
}
return state;
} catch (error) {
this._error('Error getting session state:', error);
throw error;
}
}
/**
* Gets the track status
* @async
* @param {string} trackName - The track name
* @returns {Promise<string>} The track status
*/
async getTrackStatus(trackName) {
const state = await this.getSessionState();
return state.tracks.find(t => t.trackName === trackName)?.status;
}
/**
* Updates the track status
* @async
* @private
* @param {string} trackId - The track ID
* @param {string} kind - The track kind
* @param {boolean} enabled - Whether the track is enabled
* @returns {Promise<Object>} The updated track status
*/
async _updateTrackStatus(trackId, kind, enabled) {
try {
const updateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/track-status`;
const response = await this._fetch(updateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trackId,
kind,
enabled,
force: false // Allow proper renegotiation
})
});
const result = await response.json();
if (result.errorCode) {
throw new Error(result.errorDescription || 'Unknown error updating track status');
}
// If renegotiation is needed, handle it
if (result.requiresImmediateRenegotiation) {
await this._renegotiate();
}
if (!result.errorCode) {
this._updateTrackState(trackId, enabled ? 'enabled' : 'disabled');
}
return result;
} catch (error) {
this._error(`Error updating track status:`, error);
throw error;
}
}
/**
* Handles errors
* @private
* @param {Object} response - The response object
* @returns {Object} The response object
*/
_handleError(response) {
if (response.errorCode) {
const error = new Error(response.errorDescription || 'Unknown error');
error.code = response.errorCode;
throw error;
}
return response;
}
/**
* Gets information about a user
* @async
* @param {string} [userId] - Optional user ID. If omitted, returns current user's info
* @returns {Promise<Object>} User information including moderator status
*/
async getUserInfo(userId = null) {
try {
const response = await this._fetch(
`${this.backendUrl}/api/users/${userId || 'me'}`
);
return await response.json();
} catch (error) {
this._error('Error getting user info:', error);
throw error;
}
}
/**
* Handles WebSocket messages
* @private
* @param {MessageEvent} event - The WebSocket message event
* @returns {void}
*/
_handleWebSocketMessage(event) {
try {
const message = JSON.parse(event.data);
this._log('WebSocket message received:', message);
// First, notify generic handlers
this._wsMessageHandlers.forEach(handler => {
try {
handler(message);
} catch (err) {
this._error('Error in WebSocket message handler:', err);
}
});
// Then handle specific message types
switch (message.type) {
case 'participant-joined':
if (this._onParticipantJoinedCallback) {
this._onParticipantJoinedCallback(message.payload);
}
break;
case 'participant-left':
if (this._onParticipantLeftCallback) {
this._onParticipantLeftCallback(message.payload.sessionId);
}
break;
case 'track-published':
if (this._onRemoteTrackCallback) {
// Handle track published event
this._onRemoteTrackCallback(message.payload);
}
break;
case 'track-unpublished':
if (this._onRemoteTrackUnpublishedCallback) {
this._onRemoteTrackUnpublishedCallback(
message.payload.sessionId,
message.payload.trackName
);
}
break;
case 'track-status-changed':
if (this._onTrackStatusChangedCallback) {
this._onTrackStatusChangedCallback(message.payload);
}
break;
case 'data-message':
if (this._onDataMessageCallback) {
this._onDataMessageCallback(message.payload);
}
break;
case 'room-metadata-updated':
if (this._onRoomMetadataUpdatedCallback) {
this._onRoomMetadataUpdatedCallback(message.payload);
}
break;
default:
this._log('Unhandled message type:', message.type);
}
} catch (error) {
this._error('Error handling WebSocket message:', error);
}
}
/**
* Updates track state in internal tracking
* @private
* @param {string} trackName - The track name
* @param {string} status - The new status
*/
_updateTrackState(trackName, status) {
if (!this.trackStates) {
this.trackStates = new Map();
}
this.trackStates.set(trackName, status);
}
/**
* Lists all available rooms.
* @async
* @returns {Promise<Array>} List of rooms
*/
async listRooms() {
const resp = await this._fetch(`${this.backendUrl}/api/rooms`)
.then(r => r.json());
return resp.rooms;
}
/**
* Updates room metadata.
* @async
* @param {Object} updates Metadata updates
* @param {string} [updates.name] New room name
* @param {Object} [updates.metadata] New room metadata
* @returns {Promise<Object>} Updated room information
*/
async updateRoomMetadata(updates) {
if (!this.roomId) {
return this._warn('Not connected to any room');
}
return await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/metadata`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
}).then(r => r.json());
}
/**
* Send a data message to all participants in the room via WebSocket.
* @param {Object} data - The JSON object to send.
* @returns {void}
*/
async sendDataToAll(data) {
if (!this.roomId || !this.sessionId) {
throw new Error('Must be in a room to send data');
}
// Send via WebSocket instead of HTTP
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'data-message',
payload: {
from: this.sessionId,
message: data
}
}));
} else {
throw new Error('WebSocket connection not available');
}
}
/**
* Sets the media quality for audio and video tracks
* @param {string|QualityPreset} quality - Either a preset name ('high', 'medium', 'low') or a custom quality object
* @param {VideoQualitySettings} [quality.video] - Video quality settings
* @param {AudioQualitySettings} [quality.audio] - Audio quality settings
* @throws {Error} If preset name is invalid
*/
setMediaQuality(quality) {
// If quality is a string, use the preset
if (typeof quality === 'string') {
const preset = CloudflareCalls.QUALITY_PRESETS[quality];
if (!preset) {
return this._warn(`Unknown quality preset: ${quality}`);
}
this.mediaQuality = quality;
quality = preset;
}
this.mediaQuality = {
video: { ...this.mediaQuality.video, ...quality.video },
audio: { ...this.mediaQuality.audio, ...quality.audio }
};
// Store settings to apply to future tracks
this.pendingQualitySettings = this.mediaQuality;
// If we're already in a call, update existing tracks
if (this.peerConnection) {
this._applyQualitySettings();
}
}
/**
* Applies quality settings to all tracks
* @private
*/
async _applyQualitySettings() {
if (!this.peerConnection) return;
const senders = this.peerConnection.getSenders();
for (const sender of senders) {
if (!sender.track) continue;
const params = sender.getParameters();
if (!params.encodings) {
params.encodings = [{}];
}
const kind = sender.track.kind;
const qualitySettings = this.mediaQuality[kind];
// Update bitrate
if (qualitySettings.maxBitrate) {
params.encodings[0].maxBitrate = qualitySettings.maxBitrate;
}
// Update resolution/framerate for video
if (kind === 'video') {
const constraints = {
width: qualitySettings.width,
height: qualitySettings.height,
frameRate: qualitySettings.frameRate
};
await sender.track.applyConstraints(constraints);
}
await sender.setParameters(params);
}
}
/**
* Start monitoring connection statistics
* @param {number} [interval=1000] - How often to gather stats in milliseconds
*/
startStatsMonitoring(interval = 1000) {
if (this.statsMonitoringState === 'monitoring') return;
this.statsMonitoringState = 'monitoring';
this.statsInterval = setInterval(async () => {
if (!this.peerConnection) return;
const stats = await this._gatherConnectionStats();
const streamStats = await this._gatherStreamStats();
if (this._onConnectionStatsCallback) {
this._onConnectionStatsCallback(stats, streamStats);
}
}, interval);
}
/**
* Stop monitoring connection statistics
*/
stopStatsMonitoring() {
if (this.statsInterval) {
clearInterval(this.statsInterval);
this.statsInterval = null;
// + this.previousStats = null; // Clear previous stats
}
this.statsMonitoringState = 'stopped';
}
/**
* Register a callback to receive connection statistics
* @param {function(ConnectionStats): void} callback - Function to receive stats updates
*/
onConnectionStats(callback) {
this._onConnectionStatsCallback = callback;
}
/**
* Gather current connection statistics
* @private
* @returns {Promise<ConnectionStats>} Current connection statistics
*/
async _gatherConnectionStats() {
if (!this.peerConnection) {
return this._warn('No active connection');
}
const stats = await this.peerConnection.getStats();
const result = {
outbound: {
bitrate: 0,
packetLoss: 0,
qualityLimitation: 'none'
},
inbound: {
bitrate: 0,
packetLoss: 0,
jitter: 0
},
connection: {
roundTripTime: 0,
state: this.peerConnection.connectionState
}
};
let outboundStats = null;
let inboundStats = null;
// Process each stat
stats.forEach(stat => {
switch (stat.type) {
case 'outbound-rtp':
if (stat.kind === 'video') {
outboundStats = stat;
result.outbound.qualityLimitation = stat.qualityLimitationReason;
}
break;
case 'inbound-rtp':
if (stat.kind === 'video') {
inboundStats = stat;
result.inbound.jitter = stat.jitter;
if (stat.packetsLost > 0) {
result.inbound.packetLoss =
(stat.packetsLost / (stat.packetsReceived + stat.packetsLost)) * 100;
}
}
break;
case 'candidate-pair':
if (stat.state === 'succeeded') {
result.connection.roundTripTime = stat.currentRoundTripTime;
}
break;
}
});
// Calculate bitrates using previous stats
if (this.previousStats && outboundStats && inboundStats) {
const timeDelta = (outboundStats.timestamp - this.previousStats.outboundTimestamp) / 1000; // Convert to seconds
if (timeDelta > 0) {
// Calculate outbound bitrate
const bytesSentDelta = outboundStats.bytesSent - this.previousStats.bytesSent;
result.outbound.bitrate = (bytesSentDelta * 8) / timeDelta; // Convert to bits per second
// Calculate inbound bitrate
const bytesReceivedDelta = inboundStats.bytesReceived - this.previousStats.bytesReceived;
result.inbound.bitrate = (bytesReceivedDelta * 8) / timeDelta; // Convert to bits per second
}
}
// Store current stats for next calculation
if (outboundStats && inboundStats) {
this.previousStats = {
outboundTimestamp: outboundStats.timestamp,
bytesSent: outboundStats.bytesSent,
bytesReceived: inboundStats.bytesReceived
};
}
return result;
}
/**
* Get a snapshot of current connection statistics
* @returns {Promise<ConnectionStats>} Current connection statistics
*/
async getConnectionStats() {
return this._gatherConnectionStats();
}
/**
* Gather current connection statistics per stream
* @private
* @returns {Promise<Map<string, StreamStats>>} Map of session IDs to stream stats
*/
async _gatherStreamStats() {
if (!this.peerConnection) return new Map();
const stats = await this.peerConnection.getStats();
const streamStats = new Map();
// Initialize local stats
if (this.sessionId) {
streamStats.set(this.sessionId, {
sessionId: this.sessionId,
packetLoss: 0,
qualityLimitation: 'none',
bitrate: 0
});
}
stats.forEach(stat => {
if (stat.type === 'outbound-rtp' && stat.kind === 'video') {
// Update local stream stats
const localStats = streamStats.get(this.sessionId);
if (localStats) {
localStats.qualityLimitation = stat.qualityLimitationReason;
localStats.bitrate = stat.bytesSent * 8 / stat.timestamp;
}
}
else if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
// Get sessionId from mid mapping
const mid = stat.mid;
const sessionId = this.midToSessionId.get(mid);
if (sessionId) {
streamStats.set(sessionId, {
sessionId,
packetLoss: stat.packetsLost > 0
? (stat.packetsLost / (stat.packetsReceived + stat.packetsLost)) * 100
: 0,
qualityLimitation: 'none',
bitrate: stat.bytesReceived * 8 / stat.timestamp
});
}
}
});
return streamStats;
}
// Add static QUALITY_PRESETS
static QUALITY_PRESETS = {
// 16:9 Presets
high_16x9_xl: { // 1080p
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 },
maxBitrate: 2_500_000
},
audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
},
high_16x9_lg: { // 720p
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
maxBitrate: 1_500_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 2 }
},
high_16x9_md: { // 480p
video: {
width: { ideal: 854 },
height: { ideal: 480 },
frameRate: { ideal: 30 },
maxBitrate: 800_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
},
high_16x9_sm: { // 360p
video: {
width: { ideal: 640 },
height: { ideal: 360 },
frameRate: { ideal: 30 },
maxBitrate: 600_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
high_16x9_xs: { // 270p
video: {
width: { ideal: 480 },
height: { ideal: 270 },
frameRate: { ideal: 30 },
maxBitrate: 400_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
// 16:9 Medium Quality Presets (reduced framerate & bitrate)
medium_16x9_xl: {
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 24 },
maxBitrate: 2_000_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 2 }
},
medium_16x9_lg: {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 24 },
maxBitrate: 1_200_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
},
medium_16x9_md: {
video: {
width: { ideal: 854 },
height: { ideal: 480 },
frameRate: { ideal: 24 },
maxBitrate: 600_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
medium_16x9_sm: {
video: {
width: { ideal: 640 },
height: { ideal: 360 },
frameRate: { ideal: 20 },
maxBitrate: 400_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
medium_16x9_xs: {
video: {
width: { ideal: 480 },
height: { ideal: 270 },
frameRate: { ideal: 20 },
maxBitrate: 300_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
// 16:9 Low Quality Presets (minimum viable quality)
low_16x9_xl: {
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 15 },
maxBitrate: 1_500_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
low_16x9_lg: {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 15 },
maxBitrate: 800_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
low_16x9_md: {
video: {
width: { ideal: 854 },
height: { ideal: 480 },
frameRate: { ideal: 15 },
maxBitrate: 400_000
},
audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
},
low_16x9_sm: {
video: {
width: { ideal: 640 },
height: { ideal: 360 },
frameRate: { ideal: 12 },
maxBitrate: 250_000
},
audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
},
low_16x9_xs: {
video: {
width: { ideal: 480 },
height: { ideal: 270 },
frameRate: { ideal: 10 },
maxBitrate: 150_000
},
audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
},
// 4:3 High Quality Presets (existing)
high_4x3_xl: { // 960x720
video: {
width: { ideal: 960 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
maxBitrate: 1_500_000
},
audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
},
high_4x3_lg: { // 640x480
video: {
width: { ideal: 640 },
height: { ideal: 480 },
frameRate: { ideal: 30 },
maxBitrate: 800_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
},
high_4x3_md: { // 480x360
video: {
width: { ideal: 480 },
height: { ideal: 360 },
frameRate: { ideal: 30 },
maxBitrate: 600_000
},
audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
},
high_4x3_sm: { // 320x240
video: {
width: { ideal: 320 },
height: { ideal: 240 },
frameRate: { ideal: 30 },
maxBitrate: 400_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
high_4x3_xs: { // 240x180 (perfect for 300x225 container)
video: {
width: { ideal: 240 },
height: { ideal: 180 },
frameRate: { ideal: 30 },
maxBitrate: 250_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
// 4:3 Medium Quality Presets
medium_4x3_xl: {
video: {
width: { ideal: 960 },
height: { ideal: 720 },
frameRate: { ideal: 24 },
maxBitrate: 1_200_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
},
medium_4x3_lg: {
video: {
width: { ideal: 640 },
height: { ideal: 480 },
frameRate: { ideal: 24 },
maxBitrate: 600_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
medium_4x3_md: {
video: {
width: { ideal: 480 },
height: { ideal: 360 },
frameRate: { ideal: 20 },
maxBitrate: 400_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
medium_4x3_sm: {
video: {
width: { ideal: 320 },
height: { ideal: 240 },
frameRate: { ideal: 20 },
maxBitrate: 300_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
medium_4x3_xs: {
video: {
width: { ideal: 240 },
height: { ideal: 180 },
frameRate: { ideal: 20 },
maxBitrate: 200_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
// 4:3 Low Quality Presets
low_4x3_xl: {
video: {
width: { ideal: 960 },
height: { ideal: 720 },
frameRate: { ideal: 15 },
maxBitrate: 800_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
low_4x3_lg: {
video: {
width: { ideal: 640 },
height: { ideal: 480 },
frameRate: { ideal: 15 },
maxBitrate: 400_000
},
audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
},
low_4x3_md: {
video: {
width: { ideal: 480 },
height: { ideal: 360 },
frameRate: { ideal: 12 },
maxBitrate: 250_000
},
audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
},
low_4x3_sm: {
video: {
width: { ideal: 320 },
height: { ideal: 240 },
frameRate: { ideal: 10 },
maxBitrate: 150_000
},
audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
},
low_4x3_xs: {
video: {
width: { ideal: 240 },
height: { ideal: 180 },
frameRate: { ideal: 10 },
maxBitrate: 100_000
},
audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
},
// 1:1 High Quality Presets
high_1x1_xl: { // 720x720
video: {
width: { ideal: 720 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
maxBitrate: 1_500_000
},
audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
},
high_1x1_lg: { // 480x480
video: {
width: { ideal: 480 },
height: { ideal: 480 },
frameRate: { ideal: 30 },
maxBitrate: 800_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
},
high_1x1_md: { // 360x360
video: {
width: { ideal: 360 },
height: { ideal: 360 },
frameRate: { ideal: 30 },
maxBitrate: 600_000
},
audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
},
high_1x1_sm: { // 240x240
video: {
width: { ideal: 240 },
height: { ideal: 240 },
frameRate: { ideal: 30 },
maxBitrate: 400_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
high_1x1_xs: { // 180x180
video: {
width: { ideal: 180 },
height: { ideal: 180 },
frameRate: { ideal: 30 },
maxBitrate: 250_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
// 1:1 Medium Quality Presets
medium_1x1_xl: {
video: {
width: { ideal: 720 },
height: { ideal: 720 },
frameRate: { ideal: 24 },
maxBitrate: 1_200_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
},
medium_1x1_lg: {
video: {
width: { ideal: 480 },
height: { ideal: 480 },
frameRate: { ideal: 24 },
maxBitrate: 600_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
medium_1x1_md: {
video: {
width: { ideal: 360 },
height: { ideal: 360 },
frameRate: { ideal: 20 },
maxBitrate: 400_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
medium_1x1_sm: {
video: {
width: { ideal: 240 },
height: { ideal: 240 },
frameRate: { ideal: 20 },
maxBitrate: 300_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
medium_1x1_xs: {
video: {
width: { ideal: 180 },
height: { ideal: 180 },
frameRate: { ideal: 20 },
maxBitrate: 200_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
// 1:1 Low Quality Presets
low_1x1_xl: {
video: {
width: { ideal: 720 },
height: { ideal: 720 },
frameRate: { ideal: 15 },
maxBitrate: 800_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
low_1x1_lg: {
video: {
width: { ideal: 480 },
height: { ideal: 480 },
frameRate: { ideal: 15 },
maxBitrate: 400_000
},
audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
},
low_1x1_md: {
video: {
width: { ideal: 360 },
height: { ideal: 360 },
frameRate: { ideal: 12 },
maxBitrate: 250_000
},
audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
},
low_1x1_sm: {
video: {
width: { ideal: 240 },
height: { ideal: 240 },
frameRate: { ideal: 10 },
maxBitrate: 150_000
},
audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
},
low_1x1_xs: {
video: {
width: { ideal: 180 },
height: { ideal: 180 },
frameRate: { ideal: 10 },
maxBitrate: 100_000
},
audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
},
// 9:16 High Quality Presets (Portrait/Mobile)
high_9x16_xl: { // 1080x1920
video: {
width: { ideal: 1080 },
height: { ideal: 1920 },
frameRate: { ideal: 30 },
maxBitrate: 2_500_000
},
audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
},
high_9x16_lg: { // 720x1280
video: {
width: { ideal: 720 },
height: { ideal: 1280 },
frameRate: { ideal: 30 },
maxBitrate: 1_500_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
},
high_9x16_md: { // 480x854
video: {
width: { ideal: 480 },
height: { ideal: 854 },
frameRate: { ideal: 30 },
maxBitrate: 800_000
},
audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
},
high_9x16_sm: { // 360x640
video: {
width: { ideal: 360 },
height: { ideal: 640 },
frameRate: { ideal: 30 },
maxBitrate: 600_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
high_9x16_xs: { // 270x480
video: {
width: { ideal: 270 },
height: { ideal: 480 },
frameRate: { ideal: 30 },
maxBitrate: 400_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
// 9:16 Medium Quality Presets
medium_9x16_xl: {
video: {
width: { ideal: 1080 },
height: { ideal: 1920 },
frameRate: { ideal: 24 },
maxBitrate: 2_000_000
},
audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
},
medium_9x16_lg: {
video: {
width: { ideal: 720 },
height: { ideal: 1280 },
frameRate: { ideal: 24 },
maxBitrate: 1_200_000
},
audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
},
medium_9x16_md: {
video: {
width: { ideal: 480 },
height: { ideal: 854 },
frameRate: { ideal: 20 },
maxBitrate: 600_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
medium_9x16_sm: {
video: {
width: { ideal: 360 },
height: { ideal: 640 },
frameRate: { ideal: 20 },
maxBitrate: 400_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
medium_9x16_xs: {
video: {
width: { ideal: 270 },
height: { ideal: 480 },
frameRate: { ideal: 20 },
maxBitrate: 300_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
// 9:16 Low Quality Presets
low_9x16_xl: {
video: {
width: { ideal: 1080 },
height: { ideal: 1920 },
frameRate: { ideal: 15 },
maxBitrate: 1_500_000
},
audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
},
low_9x16_lg: {
video: {
width: { ideal: 720 },
height: { ideal: 1280 },
frameRate: { ideal: 15 },
maxBitrate: 800_000
},
audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
},
low_9x16_md: {
video: {
width: { ideal: 480 },
height: { ideal: 854 },
frameRate: { ideal: 12 },
maxBitrate: 400_000
},
audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
},
low_9x16_sm: {
video: {
width: { ideal: 360 },
height: { ideal: 640 },
frameRate: { ideal: 10 },
maxBitrate: 250_000
},
audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
},
low_9x16_xs: {
video: {
width: { ideal: 270 },
height: { ideal: 480 },
frameRate: { ideal: 10 },
maxBitrate: 150_000
},
audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
}
};
}
export default CloudflareCalls;