This post will walk you through our process of migrating our telehealth application, SimplyDoc, from OpenTok to Daily. SimplyDoc is a starter kit that can be used as a quick start to building a custom telehealth application.
For more general guidance about migrating from OpenTok to Daily, you can check out Daily's OpenTok transition guide. This migration took one person about seven days using Daily's reference docs. By following the new transition guide, the implementation time could be reduced to as little as four days.
SimplyDoc application constraints
SimplyDoc was built a few years ago and uses Node.js version 12.daily-react
couldn’t be used for this migration because it uses recoil
and both packages require a peer of react@>=16.13.1
Getting started
We had to take two main components into account in the migration to Daily: SimplyDoc’s backend and frontend.
- SimplyDoc’s backend creates OpenTok session IDs and session tokens.
- SimplyDoc’s frontend contains the client-facing application and uses data retrieved from the backend.
The following diagram shows the steps taken to change both of these components to use Daily:
Backend code migration
Identify creation of OpenTok sessions and tokens
SimplyDoc's existing code for creating OpenTok (OT) sessions and tokens needs to instead create Daily rooms and tokens. The original OpenTok code was as follows:
OpenTok session generation logic
const opentok = new OpenTok(
process.env.TOKBOX_API_KEY,
process.env.TOKBOX_API_SECRET
);
opentok.createSession({ mediaMode: ‘routed’ }, (err, session) => {
if (err) {
log(‘error’, { error: `getTokboxSession: ${err.message}` });
reject(err);
return;
}
OpenTok token generation logic
const token = opentok.generateToken(session.sessionId);
Write functions to create rooms and tokens with Daily
Replacing OpenTok session and token creation logic with Daily’s rooms and meeting tokens was quite straightforward.
Daily logic to create a room
async function createDailyRoom() {
const options = {};
return new Promise((resolve, reject) => {
return fetch(`https://api.daily.co/v1/rooms/`, {
method: ‘POST’,
body: JSON.stringify(options),
headers: {
‘Content-Type’: ‘application/json’,
Authorization: ‘Bearer ‘ + process.env.TOKBOX_API_KEY,
},
}).then(r => {
console.log(‘Room Created’);
return resolve(r.json());
});
});
}
Explaining the code above, we are using Daily’s REST API to create rooms. For the Authorization Bearer token, this request is using the TOKBOX_API_KEY
environment variable, which should be updated to the Daily API key. The Daily API key can be found in your developer dashboard.
Next, we wrote the logic to obtain meeting tokens for the new room.
Daily logic to obtain a meeting token
async function createDailyToken(roomName) {
const options = {
properties: {
is_owner: true,
room_name: roomName,
},
};
const response = await fetch(`https://api.daily.co/v1/meeting-tokens/`, {
method: ‘POST’,
body: JSON.stringify(options),
headers: {
‘Content-Type’: ‘application/json’,
Authorization: `Bearer ${process.env.TOKBOX_API_KEY}`,
},
});
return response.json();
}
The createDailyToken()
function above is using Daily’s REST API to obtain a meeting token. In this case the token is for the owner, so the owner
property is specified during creation of the token. We specify the room name for which the token is to be issued using the room_name
property.
Replace OpenTok calls to use the migrated functions above
Instead of calling OpenTok library functions to create session IDs and tokens, we now use the Daily room and token creation code we wrote above. This allows us to obtain and store the Daily room URL instead of the OT session, and the Daily token instead of the OT token. The following is an example of this:
export function getTokboxSession() {
return new Promise((resolve, reject) => {
createDailyRoom().then(async room => {
const response = await createDailyToken(room.name);
const sessionId = room.url;
resolve({ token: response.token, sessionId });
});
});
}
With this adjustment, the application is going to follow SimplyDoc’s normal code path, but with the room and token values that we need for Daily. In your application, you will eventually want to rename the getTokboxSession()
function as well. Here, keeping the name allowed us to move on with the rest of the migration without increasing the diff with name changes in the rest of the code path.
Frontend migration
Device selection
Before joining a call, SimplyDoc has a preview page where you can select the camera and microphone you are going to use. SimplyDoc uses the browser native functions to get these devices, so no changes to this code were necessary.
If this was not the case, we could have used the getInputDevices()
method from daily-js
or the useDevices
hook from Daily React to get the participant’s available devices.
Network check
SimplyDoc has a network check implementation with OpenTok, which checks the quality of the user’s network. With Daily, the closest equivalent is getNetworkStats()
. We removed the existing OpenTok logic:
OpenTok logic
this.otNetworkTest
.testConnectivity()
.then(results => {
let networkCheck;
if (results.success) {
networkCheck = (
<Icon color=“green” name=“check circle” size=“large” />
);
} else {
networkCheck = (
<Icon color=“red” name=“times circle” size=“large” />
);
console.log(results);
}
if (!this.isNetworkTestCanceled) {
this.setState({ networkCheck });
this.otNetworkTest
.testQuality(stats => {
if (!this.isNetworkTestCanceled) {
this.updateProgressBar();
}
return stats;
})
.then(results => {
if (!this.isNetworkTestCanceled) {
const parsedResults = parseNetworkStats(results);
this.setState({
audioStats: parsedResults.audio,
videoStats: parsedResults.video,
audioLoading: false,
videoLoading: false,
progress: 100,
});
}
})
.catch(error => {
console.log(‘OpenTok quality test error’, error);
});
}
})
.catch(function (error) {
console.log(‘OpenTok connectivity test error’, error);
});
```javascript
We then replaced the above with the following call to `getNetworkStats()`. The information returned was not identical to OpenTok, but gives us an idea of the network quality.
#### Daily logic
```javascript
const networkStatus = callObject.getNetworkStats();
Replace OpenTok session connection logic with Daily room join logic
We need to use Daily’s call object instance to connect to a room, so we created a state variable to store this call object:
callObject: null,
In order to connect to the call, we replaced the connect()
method from OpenTok with the preAuth()
method from Daily.
The previous OpenTok connection logic looked as follows:
OpenTok logic
this.otCore = new Core(otCoreOptions);
logger.info(logHeaders.CONNECT_OPENTOK, {
isAnonymous: this.isAnonymous(),
userId: auth.viewer.userId,
appointmentId: call.appointment.appointmentId,
otSessionId: call.appointment.sessionId,
variation: ‘attempt’,
});
this.otCore
.connect()
.then(() => {
this.setState({ connected: true });
logger.info(logHeaders.CONNECT_OPENTOK, {
We replaced the code above with the following:
Daily logic
const newCallObject = DailyIframe.createCallObject();
newCallObject
.preAuth({ url: call.appointment.sessionId })
.then(response => {
this.setState({
connected: true,
callObject: newCallObject,
});
Above, we are calling Daily’s preAuth()
method. After that, we set the callObject
state variable to the call object created using createCallObject()
.
To start the call, we replaced the startCall()
method from OpenTok with the join()
method from Daily.
OpenTok startCall()
logic
this.otCore
.startCall({
insertMode: ‘append’,
facingMode: ‘user’,
resolution: ‘1280x720’,
fitMode,
publishAudio: this.state.localAudioEnabled,
publishVideo: this.state.localVideoEnabled,
videoSource: this.state.videoInputDeviceId || true,
audioSource: this.state.audioInputDeviceId || true,
})
.then(({ publishers, subscribers, meta }) => {
if (domain.name.indexOf(‘meetem’) === -1) {
this.setState({ timerStarted: true });
this.startTimer();
}
The code above was replaced with the following join()
call:
Daily logic
this.state.callObject
.join()
.then(response => {
this.handleNewParticipantsState();
if (domain.name.indexOf(‘meetem’) === -1) {
this.setState({ timerStarted: true });
this.startTimer();
}
To set the video and audio source devices, we used Daily’s setInputDevicesAsync()
call object instance method:
Daily logic
this.state.callObject.setInputDevicesAsync({
audioDeviceId: this.state.audioInputDeviceId,
videoDeviceId: this.state.videoInputDeviceId,
});
Having replaced the OpenTok code above with Daily logic, we were able to get the participants to join the call.
Replace OpenTok events with Daily events to manage participant state
In order to get participants’ presence information and other data, we need to listen for and handle relevant call events. OpenTok manages its call events differently than Daily, so we decided to remove all of that logic:
OpenTok event handling logic
const events = [
‘subscribeToCamera’,
‘unsubscribeFromCamera’,
‘subscribeToScreen’,
‘unsubscribeFromScreen’,
‘startScreenShare’,
‘endScreenShare’,
];
events.forEach(event =>
this.otCore.on(event, ({ publishers, subscribers, meta }) => {
this.setState({ publishers, subscribers, meta });
const subscriberCameras = subscribers.camera;
const subscriberSip = subscribers.sip;
for (var subscriber in subscriberCameras) {
// eslint-disable-next-line no-prototype-builtins
if (
subscriberCameras.hasOwnProperty(subscriber) &&
this.state.sipCall
) {
subscribers.camera[subscriber].subscribeToAudio(false);
}
}
for (var sip in subscriberSip) {
// eslint-disable-next-line no-prototype-builtins
if (subscriberSip.hasOwnProperty(sip) && this.state.sipCall) {
subscribers.sip[sip].subscribeToAudio(false);
}
}
})
);
this.otCore.on(‘streamCreated’, () => {
this.sendStats(hash);
});
this.otCore.on(‘connectionCreated’, event => {
const { connections } = this.props.call;
const { connection } = event;
const data = utils.getConnectionData(connection);
if (
!this.state.timerStarted &&
(connections || []).length &&
domain.name.indexOf(‘meetem’) !== -1
) {
this.startTimer();
this.setState({ timerStarted: true });
}
let d;
for (const I in connections) {
// eslint-disable-next-line no-prototype-builtins
if (!connections.hasOwnProperty(i)) continue;
d = utils.getConnectionData(connections[I]);
if (
data.user !== ‘guest’ &&
d.userId === data.userId &&
connection.connectionId ===
this.otCore.getSession().connection.connectionId
) {
event.preventDefault();
this.disconnect(false);
const errorModal = {
isOpen: true,
onRequestClose: () => {
redirectToHome();
},
title: I18n.t(‘videocall.multiple_calls_modal.title’),
message: I18n.t(‘videocall.multiple_calls_modal.message’),
};
this.setState({ errorModal });
return;
}
}
actions.ADD_CONNECTION(event.connection);
if (
this.state.active &&
connection.connectionId !==
this.otCore.getSession().connection.connectionId
) {
this.otCore.signal(‘activeConnection’, null, event.connection);
}
this.updateMicStatus(!this.state.localAudioEnabled);
});
this.otCore.on(‘connectionDestroyed’, event => {
const { micStatusUsers } = this.state;
micStatusUsers.delete(event.connection.connectionId);
this.setState({ micStatusUsers });
actions.REMOVE_CONNECTION(event.connection);
});
this.otCore.on(‘signal’, e => {
if (
e.from.connectionId ===
this.otCore.getSession().connection.connectionId
) {
return;
}
switch (e.type) {
case ‘signal:callEnded’:
this.unsubscribeToHistory();
this.cancelFullScreen();
this.disconnect();
const currentUser = parseInt(auth.viewer.userId, 10);
let errorModal;
if (
currentUser === call.appointment.userId ||
currentUser === call.appointment.customerId
) {
this.setState({ displayReviewModal: true, callError: null });
} else if (
currentUser &&
currentUser !== call.appointment.userId &&
currentUser !== call.appointment.customerId
) {
errorModal = {
isOpen: true,
onRequestClose: () => {
this.disconnect();
redirectToHome();
},
title: I18n.t(‘videocall.end_of_call’),
message: I18n.t(‘videocall.call_finished_message’),
};
this.setState({ errorModal });
} else {
let message = `${I18n.t(
‘videocall.would_you_like_to_be_part’
)} ${domain.name}`;
let okLabel = I18n.t(‘videocall.start_session’);
let redirect = ‘/signup’;
if (this.props.domain.soloDoc === ‘Y’) {
message = I18n.t(‘videocall.thanks_for_joining’);
okLabel = I18n.t(‘videocall.close’);
redirect = ‘/‘;
}
errorModal = {
isOpen: true,
onRequestClose: () => {
this.disconnect();
redirectToHome();
},
sendToSignup: () => {
this.disconnect();
redirectToHome({ toRoute: redirect });
},
title: I18n.t(‘videocall.end_of_call’),
message,
okLabel,
};
this.setState({ errorModal });
}
break;
case ‘signal:activeConnection’:
actions.SET_ACTIVE_CONNECTION(e.from.connectionId);
break;
case ‘signal:subscriberMicStatus’:
const data = JSON.parse(e.data || ‘{}’);
if (data.connectionId) {
const { mute, user, connectionId, sipCall } = data;
const { micStatusUsers } = this.state;
micStatusUsers.set(connectionId, { user, mute, sipCall });
this.setState({ micStatusUsers });
}
break;
case ‘signal:weakConnection’:
let userData;
try {
userData = JSON.parse(e.from.data);
console.log(
‘weak connection signal received from’,
userData.name
);
} catch (err) {
userData = {
name: ‘Unknown’,
};
console.log(
‘weak connection received from connection with no user data’
);
}
const userName =
userData.firstName || userData.name.split(‘ ‘)[0];
this.showToast(userName + I18n.t(‘errors.other_weak_connection’));
break;
}
});
this.otCore.on(‘sessionReconnecting’, e => {
// when client get disconnected
this.showToast(I18n.t(‘errors.weak_connection’));
this.otCore.signal(‘weakConnection’);
});
this.otCore.on(‘streamDestroyed’, e => {
// when any client is having connection issues
if (e.reason === NETWORK_DISCONNECTED) {
console.log(‘streamDestroyed’, e);
const otState = this.otCore.state();
const publisher =
otState.publishers.camera[
Object.keys(otState.publishers.camera)[0]
] ||
otState.publishers.screen[
Object.keys(otState.publishers.screen)[0]
];
if (publisher.stream.streamId === e.stream.streamId) {
this.showToast(I18n.t(‘errors.weak_connection’));
this.otCore.signal(‘weakConnection’);
} else {
const userData = JSON.parse(e.stream.connection.data);
this.showToast(
userData.name.split(‘ ‘)[0] +
I18n.t(‘errors.other_weak_connection’)
);
}
}
});
We deleted all of the OpenTok-specific code above from SimplyDoc. In order to implement equivalent behavior with Daily, we added a new state variable called participants
:
participants: [],
This array variable will store the information about each participant in the call. It is updated using the following function:
Daily logic
handleNewParticipantsState = () => {
const callParticipants = this.state.callObject.participants();
let cParticipants = [];
for (const [id, participant] of Object.entries(callParticipants)) {
cParticipants.push({ …participant, id });
}
this.setState({
participants: cParticipants,
});
};
The handleNewParticipantsState()
function above calls Daily’s participants()
method to retrieve the latest information about each participant in the call.
Our participants
state variable will be updated in response to the following Daily events:
The code to handle these events is as follows:
Daily event handling logic
const events = [
‘participant-joined’,
‘participant-updated’,
‘participant-left’,
];
for (const event of events) {
newCallObject.on(event, () => this.handleNewParticipantsState());
}
Initially, we tried to match events between OpenTok and Daily, but there wasn’t 100% parity. It became much easier and cleaner to remove OpenTok code, its events, custom elements, etc, and write logic that fully utilized Daily from scratch.
Develop UI component(s) to manage video and audio tracks for call participants
OpenTok has options to specify the subscriber and publisher video containers. These are used to show the user’s video tracks. With Daily, we created one custom component to render participants’ video and audio tracks.
In this component, we pass a prop named participant
. This object contains all relevant information about the participant, including their media tracks:
Daily logic
import React, { useEffect, useRef } from ‘react’;
export const VideoContainer = props => {
const localVideoElement = useRef(null);
const localAudioElement = useRef(null);
const { participant, isSmall, hasAudio } = props;
useEffect(() => {
if (!participant) {
return;
}
const audio = participant.tracks.audio;
const video = participant.tracks.video;
if (
video &&
video.persistentTrack &&
localVideoElement &&
localVideoElement.current
) {
localVideoElement.current.srcObject = new MediaStream([
video.persistentTrack,
]);
}
if (
audio &&
audio.persistentTrack &&
localAudioElement &&
localAudioElement.current
) {
localAudioElement.current.srcObject = new MediaStream([
audio.persistentTrack,
]);
}
}, [participant]);
return (
<React.Fragment>
<video
style={isSmall ? { width: ‘100px’ } : {}}
autoPlay
muted
playsInline
ref={localVideoElement}
/>
{hasAudio ? <audio autoPlay playsInline ref={localAudioElement} /> : null}
</React.Fragment>
);
};
Using this component, we can show the participant’s video and, if needed, also play the audio track.
Conclusion
Changing OpenTok to Daily was not as complex as we first thought, even when using daily-js
because of our application’s older React version (using daily-react
would have been even easier).
This change can be made by any developer with a little knowledge of WebRTC. The most important thing is to know where the video and audio tracks are and how to add them to the code. Aside from the Daily reference docs, we used Daily’s demos to get more context and examples of API usage. Daily's OpenTok transition guide also provides a framework for this kind of migration.