A full-stack video calling application built with React, Node.js, and Daily.js.
- Real-time video calling
- Audio/video controls
- Multi-participant rooms
- User authentication
- Responsive design
- Frontend: React, TypeScript, Ant Design
- Backend: Node.js, Express
- Video: Daily.js
- Authentication: JWT
- Node.js (v14 or higher)
- npm or yarn
- Daily.js API key
- Clone the repository:
git clone https://github.com/sandysameh/roomies.git
cd roomies- Install root dependencies:
npm install- Install client dependencies:
cd client
npm install
cd ..- Install server dependencies:
cd server
npm install
cd ..- Server Environment - Copy and configure:
cp server/.env.example server/.envThen edit server/.env with your values:
DAILY_API_KEY: Your Daily.js API keyJWT_SECRET: A secure random string for JWT tokensPORT: Server port (default: 5000)CLIENT_URL: Frontend URL (default: http://localhost:3000)
- Client Environment - Copy and configure:
cp client/.env.example client/.envThen edit client/.env with your values:
REACT_APP_API_URL: Backend API URL (default: http://localhost:5000/api)
- Development Mode (runs both client and server):
npm run dev- Or run separately:
Start the server:
cd server
npm startStart the client:
cd client
npm startThe application will be available at:
- Frontend: http://localhost:3000
- Backend: http://localhost:5000
This project was built following the Daily.js Custom Video App with React Hooks tutorial, which provides a comprehensive guide to building custom video applications with React and the Daily.js API.
Daily.js is a powerful WebRTC platform that provides APIs for building video and audio calling applications. Our application uses both the Daily.js REST API (for room management) and the Daily.js client library (for handling video calls in the browser).
Our backend server consumes the following Daily.js REST API endpoints to manage rooms:
Documentation: https://docs.daily.co/reference/rest-api/rooms/list-rooms
Purpose: Retrieves a list of all available rooms in your Daily.js account.
Usage in our app: We use this endpoint to display all available rooms to the user on the dashboard, allowing them to see which rooms they can join.
Example Response:
{
"data": [
{
"id": "room-id-123",
"name": "my-awesome-room",
"config": {},
"created_at": "2025-01-01T12:00:00.000Z"
}
]
}Documentation: https://docs.daily.co/reference/rest-api/rooms/create-room
Purpose: Creates a new video call room with custom configuration.
Usage in our app: When a user clicks "Create Room" in the dashboard, we call this endpoint to create a new room with specific settings (like room name, privacy settings, etc.).
Example Request:
{
"name": "my-meeting-room",
"privacy": "public",
"properties": {
"enable_chat": true,
"enable_screenshare": true,
"max_participants": 10
}
}Example Response:
{
"id": "room-id-456",
"name": "my-meeting-room",
"url": "https://yourapp.daily.co/my-meeting-room",
"created_at": "2025-01-01T12:30:00.000Z"
}Documentation: https://docs.daily.co/reference/rest-api/rooms/get-room-config
Purpose: Retrieves detailed information about a specific room.
Usage in our app: Before joining a room, we fetch its configuration to verify the room exists and to get information like the room URL, privacy settings, and other properties.
Example: GET /rooms/my-meeting-room
Example Response:
{
"id": "room-id-456",
"name": "my-meeting-room",
"url": "https://yourapp.daily.co/my-meeting-room",
"privacy": "public",
"config": {
"max_participants": 10,
"enable_chat": true
}
}Documentation: https://docs.daily.co/reference/rest-api/rooms/delete-room
Purpose: Permanently deletes a room from your Daily.js account.
Usage in our app: While not required for basic functionality, this endpoint can be used to clean up old or unused rooms. We haven't implemented this in the main UI, but it's available in the backend API if needed.
Example: DELETE /rooms/my-old-room
Documentation: https://docs.daily.co/reference/rest-api/rooms/get-room-presence
Purpose: Returns information about who is currently in a room.
Usage in our app: We use this endpoint to display the number of participants currently in a room before a user joins. This helps users see which rooms are active and how many people are already in a call.
Example: GET /rooms/my-meeting-room/presence
Example Response:
{
"total_count": 3,
"participants": {
"user-123": {
"id": "user-123",
"name": "John Doe",
"joined_at": "2025-01-01T13:00:00.000Z"
}
}
}What this means for users: If you see a room has 3 participants, you know there's an active conversation happening, and you can decide whether to join or create a new room.
While the REST API manages rooms on the server, the Daily.js client library handles the actual video call experience in the browser. Here's how we use it:
Documentation: https://docs.daily.co/reference/daily-js/instance-methods/join
The Golden Rule: We only create ONE Daily.js call instance per session using getCallInstance().
Why is this important? Think of the call instance like a phone connection. You don't open multiple phone lines for the same call - you establish one connection and use it for the entire conversation. Creating multiple instances would:
- Waste computer resources
- Cause confusing audio/video duplicates
- Lead to unpredictable behavior
How we implement this:
// In our DailyContext.tsx
const getCallInstance = useCallback(() => {
if (callObjectRef.current) {
return callObjectRef.current; // Return existing instance
}
// Only create a new instance if one doesn't exist
const newCallObject = DailyIframe.createCallObject();
callObjectRef.current = newCallObject;
return newCallObject;
}, []);What this code does (in simple terms):
- First, check if we already have a "phone line" (call instance)
- If yes, use the existing one
- If no, create a new one and save it for future use
- This ensures we NEVER create duplicate instances
Documentation: https://docs.daily.co/reference/daily-js/instance-methods/join
What is "joining"? Joining a room is like entering a video conference. Once you join:
- Your camera and microphone become available to the room
- You can see and hear other participants
- Others can see and hear you (if your camera/mic are enabled)
How we join a room:
const joinRoom = async (roomUrl: string) => {
const callObject = getCallInstance(); // Get our single instance
await callObject.join({
url: roomUrl,
userName: "Your Name",
audioEnabled: true, // Start with mic on
videoEnabled: false, // Start with camera off
});
};Breaking this down:
url: The Daily.js room URL (e.g., "https://yourapp.daily.co/my-room")userName: Your display name that others will seeaudioEnabled: Whether your microphone starts on (true) or muted (false)videoEnabled: Whether your camera starts on (true) or off (false)
What happens after joining:
- Daily.js connects you to the room
- You receive information about other participants
- Video/audio streams start flowing
- Events start firing (like
participant-joined,track-started, etc.)
Documentation: https://docs.daily.co/reference/daily-js/instance-methods/leave
What is "leaving"? Leaving is like hanging up the phone. It:
- Disconnects you from the video call
- Stops your camera and microphone
- Removes you from other participants' views
- Cleans up video/audio resources
How we leave a room:
const leaveRoom = async () => {
const callObject = getCallInstance();
if (callObject) {
await callObject.leave();
await callObject.destroy(); // Clean up completely
}
};Breaking this down:
leave(): Disconnects from the current roomdestroy(): Completely cleans up the call instance (releases camera, mic, memory)
Important Note: After calling destroy(), you'll need to create a new call instance to join another room. That's why we clear our saved instance after destroying:
callObjectRef.current = null; // Reset so we can create a new instance laterDocumentation: https://docs.daily.co/reference/daily-js/events
Events are like notifications that tell you when something happens in the video call. Think of them as your app's "eyes and ears" for the call.
This is one of the MOST IMPORTANT events you'll work with. It fires whenever ANYTHING changes about a participant.
When does it fire?
- Someone turns their camera on or off
- Someone mutes or unmutes their microphone
- Someone starts or stops screen sharing
- Someone's network quality changes
- Someone's name changes
Why is this important? Your UI needs to react to these changes in real-time. If John mutes his mic, everyone should immediately see a "muted" icon next to his name.
How we use it:
// In our useDailyEvents.ts
call.on("participant-updated", (event: any) => {
const participant = event.participant;
// If this is the local user (you)
if (participant.local) {
// Check if audio track is actually playing
const audioEnabled = participant.tracks?.audio?.state === "playable";
// Check if video track is actually playing
const videoEnabled = participant.tracks?.video?.state === "playable";
// Update our UI to show current mic/camera state
updateMediaStates(audioEnabled, videoEnabled);
}
// Update the participant list for everyone
onParticipantUpdate(call);
});Breaking this down:
-
participant.local: This tells us if the update is about YOU or someone elsetrue= the update is about you (your camera, your mic)false= the update is about another participant
-
participant.tracks?.audio?.state: This tells us the actual state of the audio track"playable"= audio is on and working"off"orundefined= audio is off/muted
-
participant.tracks?.video?.state: Same as audio, but for video"playable"= camera is on and streaming"off"orundefined= camera is off
-
Why check
state === "playable"instead of just checking if track exists?- A track can exist but be paused/stopped/blocked
- Checking
"playable"ensures it's ACTUALLY working - This prevents showing a "camera on" icon when the camera is actually off
Real-world example: Imagine you're in a call and you click the "mute" button:
- Your app calls
callObject.setLocalAudio(false) - Daily.js processes this and updates your audio state
- Daily.js fires a
participant-updatedevent - Our event listener catches it and sees
audio.stateis no longer"playable" - We update the UI to show a muted microphone icon
- Everyone else in the call ALSO gets a
participant-updatedevent - Their UIs update to show you're muted
This all happens in milliseconds, creating a real-time experience!
Media controls allow users to turn their camera and microphone on and off during a call.
The Challenge: We need to keep track of whether the user's mic and camera are on or off, and we need to keep our UI in sync with the actual state.
Our Solution: We maintain state variables and update them whenever the state changes:
// In useMediaControls.ts
const [localAudio, setLocalAudio] = useState(true); // Mic starts ON
const [localVideo, setLocalVideo] = useState(false); // Camera starts OFFWhy start with these values?
- Audio ON: Most people want to speak immediately when joining
- Video OFF: Gives users privacy until they're ready to be seen
What happens when you click the microphone button?
const toggleAudio = async () => {
// 1. Prevent clicking too fast (debouncing)
if (audioToggling || !callObject) return;
// 2. Set a "loading" state so button appears disabled
setAudioToggling(true);
// 3. Determine new state (if currently on, turn off; if off, turn on)
const newAudioState = !localAudio;
try {
// 4. Tell Daily.js to actually change the audio state
await callObject.setLocalAudio(newAudioState);
// 5. Update our local state to match
setLocalAudio(newAudioState);
// 6. Show a friendly message to the user
message.info(newAudioState ? "Microphone unmuted" : "Microphone muted");
} catch (error) {
// 7. If something goes wrong, show an error and revert state
console.error("Error toggling audio:", error);
message.error("Failed to toggle microphone");
setLocalAudio(!newAudioState); // Revert to previous state
} finally {
// 8. Re-enable the button after a short delay
setTimeout(() => setAudioToggling(false), 200);
}
};Step-by-step explanation:
- Prevent double-clicks: If someone clicks the button twice very quickly, we ignore the second click
- Visual feedback: Set
audioToggling = trueso we can show a loading spinner or disabled state - Calculate new state: If mic is currently on, we want to turn it off (and vice versa)
- Make the actual change:
setLocalAudio()tells Daily.js to mute/unmute your microphone - Update UI state: We update our React state so the button shows the correct icon
- User feedback: Show a toast message confirming the action
- Error handling: If something fails (no mic permission, hardware issue), we:
- Log the error for debugging
- Show an error message to the user
- Revert our state back to what it was before
- Re-enable button: After 200ms, allow the button to be clicked again
Why is error handling so important?
- User might revoke microphone permission mid-call
- Microphone hardware might fail
- Browser might block audio for policy reasons
- We need to handle these gracefully instead of breaking the app
Video is more complex than audio because:
- Cameras need to be "started" before they can stream
- Camera permissions are more likely to be denied
- Video uses more resources, so failures are more common
const toggleVideo = async () => {
if (videoToggling || !callObject) return;
setVideoToggling(true);
const newVideoState = !localVideo;
try {
if (newVideoState) {
// Turning camera ON requires two steps:
// Step 1: Enable the video track
await callObject.setLocalVideo(true);
// Step 2: Start the camera hardware
try {
await callObject.startCamera();
} catch (startError) {
// startCamera() might fail if already started - that's okay
console.error("startCamera() warning:", startError);
}
} else {
// Turning camera OFF is simpler - just disable the track
await callObject.setLocalVideo(false);
}
setLocalVideo(newVideoState);
message.info(newVideoState ? "Camera turned on" : "Camera turned off");
} catch (error) {
console.error("Error toggling video:", error);
message.error(`Failed to ${newVideoState ? "enable" : "disable"} camera`);
setLocalVideo(!newVideoState);
} finally {
// Video needs slightly more time to stabilize
setTimeout(() => setVideoToggling(false), 300);
}
};Why is turning camera ON a two-step process?
-
setLocalVideo(true): This tells Daily.js "I want to send video"- Creates a video track in the call
- Reserves bandwidth for video
- Signals to other participants that video is coming
-
startCamera(): This actually powers on the camera hardware- Requests camera permission from the browser (if not already granted)
- Initializes the camera hardware
- Starts capturing video frames
Why the extra try/catch around startCamera()?
- If the camera is already running,
startCamera()might throw an error - This is actually fine - the camera is already on
- We catch and log it, but don't treat it as a failure
Why longer timeout for video (300ms vs 200ms)?
- Camera hardware takes longer to initialize than microphone
- Video frames need time to start flowing
- This prevents UI flickering or premature re-enables
Here's the critical connection: Our media controls need to stay in sync with Daily.js events.
The problem: State can change from multiple sources:
- User clicks the button in our UI
- Another participant mutes us (in some apps)
- Browser loses camera permission
- Hardware disconnects
Our solution: Update state from both directions:
// Method 1: User clicks button → Update Daily.js → Update UI
toggleAudio() → callObject.setLocalAudio() → setLocalAudio()
// Method 2: Daily.js events → Update UI
participant-updated event → updateMediaStates() → setLocalAudio()The updateMediaStates function:
const updateMediaStates = (audio: boolean, video: boolean) => {
setLocalAudio(audio);
setLocalVideo(video);
};This is called from our event listener whenever Daily.js tells us the state changed:
call.on("participant-updated", (event) => {
if (event.participant.local) {
const audioEnabled = event.participant.tracks?.audio?.state === "playable";
const videoEnabled = event.participant.tracks?.video?.state === "playable";
// Update our media controls to match reality
updateMediaStates(audioEnabled, videoEnabled);
}
});Why is this bidirectional sync so important?
- Without it: UI shows camera on, but camera is actually off → Confusion!
- With it: UI always reflects the true state → Users trust the interface
Real-world example:
- User clicks "turn on camera"
- Browser shows permission dialog
- User clicks "Block"
- Daily.js tries to turn on camera but fails
participant-updatedfires withvideo.state = "off"- Our
updateMediaStatessetslocalVideo = false - UI shows camera as off, matching reality
- User sees error message explaining what happened
This creates a robust, predictable experience even when things go wrong!
roomies/
├── client/ # React frontend
├── server/ # Node.js backend
├── package.json # Root package.json for scripts
└── README.md # This file