Merge branch 'v2.2.x-release' of github.com:bigbluebutton/bigbluebutton into 09-16-merge
This commit is contained in:
commit
377dc27a8d
@ -70,6 +70,16 @@ public class ApiParams {
|
||||
public static final String LOCK_SETTINGS_LOCK_ON_JOIN = "lockSettingsLockOnJoin";
|
||||
public static final String LOCK_SETTINGS_LOCK_ON_JOIN_CONFIGURABLE = "lockSettingsLockOnJoinConfigurable";
|
||||
|
||||
// New param passed on create call to callback when meeting ends.
|
||||
// This is a duplicate of the endCallbackUrl meta param as we want this
|
||||
// param to stay on the server and not propagated to client and recordings.
|
||||
public static final String MEETING_ENDED_CALLBACK_URL = "meetingEndedURL";
|
||||
|
||||
// Param to end the meeting when there are no moderators after a certain period of time.
|
||||
// Needed for classes where teacher gets disconnected and can't get back in. Prevents
|
||||
// students from running amok.
|
||||
public static final String END_WHEN_NO_MODERATOR = "endWhenNoModerator";
|
||||
|
||||
private ApiParams() {
|
||||
throw new IllegalStateException("ApiParams is a utility class. Instanciation is forbidden.");
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.http.client.utils.URIBuilder;
|
||||
import org.bigbluebutton.api.domain.GuestPolicy;
|
||||
import org.bigbluebutton.api.domain.Meeting;
|
||||
@ -778,27 +779,38 @@ public class MeetingService implements MessageListener {
|
||||
|
||||
String endCallbackUrl = "endCallbackUrl".toLowerCase();
|
||||
Map<String, String> metadata = m.getMetadata();
|
||||
if (!m.isBreakout() && metadata.containsKey(endCallbackUrl)) {
|
||||
String callbackUrl = metadata.get(endCallbackUrl);
|
||||
try {
|
||||
if (!m.isBreakout()) {
|
||||
if (metadata.containsKey(endCallbackUrl)) {
|
||||
String callbackUrl = metadata.get(endCallbackUrl);
|
||||
try {
|
||||
callbackUrl = new URIBuilder(new URI(callbackUrl))
|
||||
.addParameter("recordingmarks", m.haveRecordingMarks() ? "true" : "false")
|
||||
.addParameter("meetingID", m.getExternalId()).build().toURL().toString();
|
||||
callbackUrlService.handleMessage(new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), callbackUrl));
|
||||
} catch (MalformedURLException e) {
|
||||
log.error("Malformed URL in callback url=[{}]", callbackUrl, e);
|
||||
} catch (URISyntaxException e) {
|
||||
log.error("URI Syntax error in callback url=[{}]", callbackUrl, e);
|
||||
} catch (Exception e) {
|
||||
log.error("Error in callback url=[{}]", callbackUrl, e);
|
||||
.addParameter("recordingmarks", m.haveRecordingMarks() ? "true" : "false")
|
||||
.addParameter("meetingID", m.getExternalId()).build().toURL().toString();
|
||||
MeetingEndedEvent event = new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), callbackUrl);
|
||||
processMeetingEndedCallback(event);
|
||||
} catch (Exception e) {
|
||||
log.error("Error in callback url=[{}]", callbackUrl, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (! StringUtils.isEmpty(m.getMeetingEndedCallbackURL())) {
|
||||
String meetingEndedCallbackURL = m.getMeetingEndedCallbackURL();
|
||||
callbackUrlService.handleMessage(new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), meetingEndedCallbackURL));
|
||||
}
|
||||
}
|
||||
|
||||
processRemoveEndedMeeting(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void processMeetingEndedCallback(MeetingEndedEvent event) {
|
||||
try {
|
||||
callbackUrlService.handleMessage(event);
|
||||
} catch (Exception e) {
|
||||
log.error("Error in callback url=[{}]", event.getCallbackUrl(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void userJoined(UserJoined message) {
|
||||
Meeting m = getMeeting(message.meetingId);
|
||||
if (m != null) {
|
||||
|
@ -114,6 +114,7 @@ public class ParamsProcessorUtil {
|
||||
private Integer userInactivityThresholdInMinutes = 30;
|
||||
private Integer userActivitySignResponseDelayInMinutes = 5;
|
||||
private Boolean defaultAllowDuplicateExtUserid = true;
|
||||
private Boolean defaultEndWhenNoModerator = false;
|
||||
|
||||
private String formatConfNum(String s) {
|
||||
if (s.length() > 5) {
|
||||
@ -420,6 +421,15 @@ public class ParamsProcessorUtil {
|
||||
}
|
||||
}
|
||||
|
||||
boolean endWhenNoModerator = defaultEndWhenNoModerator;
|
||||
if (!StringUtils.isEmpty(params.get(ApiParams.END_WHEN_NO_MODERATOR))) {
|
||||
try {
|
||||
endWhenNoModerator = Boolean.parseBoolean(params.get(ApiParams.END_WHEN_NO_MODERATOR));
|
||||
} catch (Exception ex) {
|
||||
log.warn("Invalid param [endWhenNoModerator] for meeting=[{}]", internalMeetingId);
|
||||
}
|
||||
}
|
||||
|
||||
String guestPolicy = defaultGuestPolicy;
|
||||
if (!StringUtils.isEmpty(params.get(ApiParams.GUEST_POLICY))) {
|
||||
guestPolicy = params.get(ApiParams.GUEST_POLICY);
|
||||
@ -487,6 +497,13 @@ public class ParamsProcessorUtil {
|
||||
meeting.setModeratorOnlyMessage(moderatorOnlyMessage);
|
||||
}
|
||||
|
||||
if (!StringUtils.isEmpty(params.get(ApiParams.MEETING_ENDED_CALLBACK_URL))) {
|
||||
String meetingEndedCallbackURL = params.get(ApiParams.MEETING_ENDED_CALLBACK_URL);
|
||||
meeting.setMeetingEndedCallbackURL(meetingEndedCallbackURL);
|
||||
}
|
||||
|
||||
meeting.setMaxInactivityTimeoutMinutes(maxInactivityTimeoutMinutes);
|
||||
meeting.setWarnMinutesBeforeMax(warnMinutesBeforeMax);
|
||||
meeting.setMeetingExpireIfNoUserJoinedInMinutes(meetingExpireIfNoUserJoinedInMinutes);
|
||||
meeting.setMeetingExpireWhenLastUserLeftInMinutes(meetingExpireWhenLastUserLeftInMinutes);
|
||||
meeting.setUserInactivityInspectTimerInMinutes(userInactivityInspectTimerInMinutes);
|
||||
@ -1115,4 +1132,10 @@ public class ParamsProcessorUtil {
|
||||
public void setAllowDuplicateExtUserid(Boolean allow) {
|
||||
this.defaultAllowDuplicateExtUserid = allow;
|
||||
}
|
||||
|
||||
public void setEndWhenNoModerator(Boolean val) {
|
||||
this.defaultEndWhenNoModerator = val;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -92,6 +92,11 @@ public class Meeting {
|
||||
|
||||
public final Boolean allowDuplicateExtUserid;
|
||||
|
||||
private String meetingEndedCallbackURL = "";
|
||||
|
||||
public final Boolean endWhenNoModerator;
|
||||
|
||||
|
||||
public Meeting(Meeting.Builder builder) {
|
||||
name = builder.name;
|
||||
extMeetingId = builder.externalId;
|
||||
@ -120,7 +125,8 @@ public class Meeting {
|
||||
guestPolicy = builder.guestPolicy;
|
||||
breakoutRoomsParams = builder.breakoutRoomsParams;
|
||||
lockSettingsParams = builder.lockSettingsParams;
|
||||
allowDuplicateExtUserid = builder.allowDuplicateExtUserid;
|
||||
allowDuplicateExtUserid = builder.allowDuplicateExtUserid;
|
||||
endWhenNoModerator = builder.endWhenNoModerator;
|
||||
|
||||
userCustomData = new HashMap<>();
|
||||
|
||||
@ -563,6 +569,14 @@ public class Meeting {
|
||||
this.userActivitySignResponseDelayInMinutes = userActivitySignResponseDelayInMinutes;
|
||||
}
|
||||
|
||||
public String getMeetingEndedCallbackURL() {
|
||||
return meetingEndedCallbackURL;
|
||||
}
|
||||
|
||||
public void setMeetingEndedCallbackURL(String meetingEndedCallbackURL) {
|
||||
this.meetingEndedCallbackURL = meetingEndedCallbackURL;
|
||||
}
|
||||
|
||||
public Map<String, Object> getUserCustomData(String userID){
|
||||
return (Map<String, Object>) userCustomData.get(userID);
|
||||
}
|
||||
@ -612,6 +626,7 @@ public class Meeting {
|
||||
private BreakoutRoomsParams breakoutRoomsParams;
|
||||
private LockSettingsParams lockSettingsParams;
|
||||
private Boolean allowDuplicateExtUserid;
|
||||
private Boolean endWhenNoModerator;
|
||||
|
||||
public Builder(String externalId, String internalId, long createTime) {
|
||||
this.externalId = externalId;
|
||||
@ -743,6 +758,11 @@ public class Meeting {
|
||||
this.allowDuplicateExtUserid = allowDuplicateExtUserid;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withEndWhenNoModerator(Boolean endWhenNoModerator) {
|
||||
this.endWhenNoModerator = endWhenNoModerator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Meeting build() {
|
||||
return new Meeting(this);
|
||||
|
@ -5,8 +5,8 @@
|
||||
# which (if exists) will be run by `bbb-conf --setip` and `bbb-conf --restart` before restarting
|
||||
# BigBlueButton.
|
||||
#
|
||||
# The purpose of apply-config.sh is to make it easy for you apply defaults to BigBlueButton server that get applied after
|
||||
# each package update (since the last step in doing an upate is to run `bbb-conf --setip`.
|
||||
# The purpose of apply-config.sh is to make it easy to apply your configuration changes to a BigBlueButton server
|
||||
# before BigBlueButton starts
|
||||
#
|
||||
|
||||
|
||||
@ -74,7 +74,19 @@ HERE
|
||||
}
|
||||
|
||||
|
||||
# Enable firewall rules to lock down access to server
|
||||
enableHTML5CameraQualityThresholds() {
|
||||
echo " - Enable HTML5 cameraQualityThresholds"
|
||||
yq w -i $HTML5_CONFIG public.kurento.cameraQualityThresholds.enabled true
|
||||
}
|
||||
|
||||
enableHTML5WebcamPagination() {
|
||||
echo " - Enable HTML5 webcam pagination"
|
||||
yq w -i $HTML5_CONFIG public.kurento.pagination.enabled true
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Enable firewall rules to open only
|
||||
#
|
||||
enableUFWRules() {
|
||||
echo " - Enable Firewall and opening 22/tcp, 80/tcp, 443/tcp and 16384:32768/udp"
|
||||
@ -90,6 +102,123 @@ enableUFWRules() {
|
||||
}
|
||||
|
||||
|
||||
enableMultipleKurentos() {
|
||||
echo " - Configuring three Kurento Media Servers: one for listen only, webcam, and screeshare"
|
||||
|
||||
# Step 1. Setup shared certificate between FreeSWITCH and Kurento
|
||||
|
||||
HOSTNAME=$(cat /etc/nginx/sites-available/bigbluebutton | grep -v '#' | sed -n '/server_name/{s/.*server_name[ ]*//;s/;//;p}' | cut -d' ' -f1 | head -n 1)
|
||||
openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 3650 -subj "/C=BR/ST=Ottawa/O=BigBlueButton Inc./OU=Live/CN=$HOSTNAME" -keyout /tmp/dtls-srtp-key.pem -out /tmp/dtls-srtp-cert.pem
|
||||
cat /tmp/dtls-srtp-key.pem /tmp/dtls-srtp-cert.pem > /etc/kurento/dtls-srtp.pem
|
||||
cat /tmp/dtls-srtp-key.pem /tmp/dtls-srtp-cert.pem > /opt/freeswitch/etc/freeswitch/tls/dtls-srtp.pem
|
||||
|
||||
sed -i 's/;pemCertificateRSA=.*/pemCertificateRSA=\/etc\/kurento\/dtls-srtp.pem/g' /etc/kurento/modules/kurento/WebRtcEndpoint.conf.ini
|
||||
|
||||
# Step 2. Setup systemd unit files to launch three separate instances of Kurento
|
||||
|
||||
for i in `seq 8888 8890`; do
|
||||
|
||||
cat > /usr/lib/systemd/system/kurento-media-server-${i}.service << HERE
|
||||
# /usr/lib/systemd/system/kurento-media-server-#{i}.service
|
||||
[Unit]
|
||||
Description=Kurento Media Server daemon (${i})
|
||||
After=network.target
|
||||
PartOf=kurento-media-server.service
|
||||
After=kurento-media-server.service
|
||||
|
||||
[Service]
|
||||
UMask=0002
|
||||
Environment=KURENTO_LOGS_PATH=/var/log/kurento-media-server
|
||||
Environment=KURENTO_CONF_FILE=/etc/kurento/kurento-${i}.conf.json
|
||||
User=kurento
|
||||
Group=kurento
|
||||
LimitNOFILE=1000000
|
||||
ExecStartPre=-/bin/rm -f /var/kurento/.cache/gstreamer-1.5/registry.x86_64.bin
|
||||
ExecStart=/usr/bin/kurento-media-server --gst-debug-level=3 --gst-debug="3,Kurento*:4,kms*:4,KurentoWebSocketTransport:5"
|
||||
Type=simple
|
||||
PIDFile=/var/run/kurento-media-server-${i}.pid
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=kurento-media-server.service
|
||||
|
||||
HERE
|
||||
|
||||
# Make a new configuration file each instance of Kurento that binds to a different port
|
||||
cp /etc/kurento/kurento.conf.json /etc/kurento/kurento-${i}.conf.json
|
||||
sed -i "s/8888/${i}/g" /etc/kurento/kurento-${i}.conf.json
|
||||
|
||||
done
|
||||
|
||||
# Step 3. Override the main kurento-media-server unit to start/stop the three Kurento instances
|
||||
|
||||
cat > /etc/systemd/system/kurento-media-server.service << HERE
|
||||
[Unit]
|
||||
Description=Kurento Media Server
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/true
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
HERE
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
for i in `seq 8888 8890`; do
|
||||
systemctl enable kurento-media-server-${i}.service
|
||||
done
|
||||
|
||||
|
||||
# Step 4. Modify bbb-webrtc-sfu config to use the three Kurento servers
|
||||
|
||||
KURENTO_CONFIG=/usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml
|
||||
|
||||
MEDIA_TYPE=(main audio content)
|
||||
IP=$(yq r /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml kurento[0].ip)
|
||||
|
||||
for i in `seq 0 2`; do
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].ip" $IP
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].url" "ws://127.0.0.1:$(($i + 8888))/kurento"
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].mediaType" "${MEDIA_TYPE[$i]}"
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.local" ""
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.private" ""
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.public" ""
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].options.failAfter" 5
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].options.request_timeout" 30000
|
||||
yq w -i $KURENTO_CONFIG "kurento[$i].options.response_timeout" 30000
|
||||
done
|
||||
|
||||
yq w -i $KURENTO_CONFIG balancing-strategy MEDIA_TYPE
|
||||
}
|
||||
|
||||
disableMultipleKurentos() {
|
||||
echo " - Configuring a single Kurento Media Server for listen only, webcam, and screeshare"
|
||||
systemctl stop kurento-media-server.service
|
||||
|
||||
for i in `seq 8888 8890`; do
|
||||
systemctl disable kurento-media-server-${i}.service
|
||||
done
|
||||
|
||||
# Remove the overrride (restoring the original kurento-media-server.service unit file)
|
||||
rm -f /etc/systemd/system/kurento-media-server.service
|
||||
systemctl daemon-reload
|
||||
|
||||
# Restore bbb-webrtc-sfu configuration to use a single instance of Kurento
|
||||
KURENTO_CONFIG=/usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml
|
||||
yq d -i $KURENTO_CONFIG kurento[1]
|
||||
yq d -i $KURENTO_CONFIG kurento[1]
|
||||
|
||||
yq w -i $KURENTO_CONFIG "kurento[0].url" "ws://127.0.0.1:8888/kurento"
|
||||
yq w -i $KURENTO_CONFIG "kurento[0].mediaType" ""
|
||||
|
||||
yq w -i $KURENTO_CONFIG balancing-strategy ROUND_ROBIN
|
||||
}
|
||||
|
||||
|
||||
|
||||
notCalled() {
|
||||
#
|
||||
# This function is not called.
|
||||
@ -112,6 +241,9 @@ source /etc/bigbluebutton/bbb-conf/apply-lib.sh
|
||||
#enableHTML5ClientLog
|
||||
#enableUFWRules
|
||||
|
||||
#enableHTML5CameraQualityThresholds
|
||||
#enableHTML5WebcamPagination
|
||||
|
||||
HERE
|
||||
chmod +x /etc/bigbluebutton/bbb-conf/apply-config.sh
|
||||
## Stop Copying HERE
|
||||
|
@ -1996,16 +1996,17 @@ if [ -n "$HOST" ]; then
|
||||
#fi
|
||||
fi
|
||||
|
||||
ESL_PASSWORD=$(xmlstarlet sel -t -m 'configuration/settings/param[@name="password"]' -v @value /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml)
|
||||
#
|
||||
# Update ESL passwords in three configuration files
|
||||
#
|
||||
ESL_PASSWORD=$(cat /usr/share/bbb-fsesl-akka/conf/application.conf | grep password | head -n 1 | sed 's/.*="//g' | sed 's/"//g')
|
||||
if [ "$ESL_PASSWORD" == "ClueCon" ]; then
|
||||
ESL_PASSWORD=$(openssl rand -hex 8)
|
||||
echo "Changing default password for FreeSWITCH Event Socket Layer (see /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml)"
|
||||
sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /usr/share/bbb-fsesl-akka/conf/application.conf
|
||||
fi
|
||||
# Update all references to ESL password
|
||||
|
||||
sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml
|
||||
sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /usr/share/bbb-fsesl-akka/conf/application.conf
|
||||
sudo yq w -i /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml freeswitch.esl_password "$ESL_PASSWORD"
|
||||
sudo xmlstarlet edit --inplace --update 'configuration/settings//param[@name="password"]/@value' --value $ESL_PASSWORD /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml
|
||||
|
||||
|
||||
echo "Restarting the BigBlueButton $BIGBLUEBUTTON_RELEASE ..."
|
||||
|
@ -6,11 +6,14 @@ import { extractCredentials } from '/imports/api/common/server/helpers';
|
||||
export default function createBreakoutRoom(rooms, durationInMinutes, record = false) {
|
||||
const REDIS_CONFIG = Meteor.settings.private.redis;
|
||||
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
|
||||
const BREAKOUT_LIM = Meteor.settings.public.app.breakoutRoomLimit;
|
||||
const MIN_BREAKOUT_ROOMS = 2;
|
||||
const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS;
|
||||
|
||||
const { meetingId, requesterUserId } = extractCredentials(this.userId);
|
||||
|
||||
const eventName = 'CreateBreakoutRoomsCmdMsg';
|
||||
if (rooms.length > 8) return Logger.info(`Attempt to create breakout rooms with invalid number of rooms in meeting id=${meetingId}`);
|
||||
if (rooms.length > MAX_BREAKOUT_ROOMS) return Logger.info(`Attempt to create breakout rooms with invalid number of rooms in meeting id=${meetingId}`);
|
||||
const payload = {
|
||||
record,
|
||||
durationInMinutes,
|
||||
|
@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
|
||||
import { check } from 'meteor/check';
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
import Meetings from '/imports/api/meetings';
|
||||
import Users from '/imports/api/users';
|
||||
import RedisPubSub from '/imports/startup/server/redis';
|
||||
import { extractCredentials } from '/imports/api/common/server/helpers';
|
||||
|
||||
@ -10,16 +11,29 @@ export default function startWatchingExternalVideo(options) {
|
||||
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
|
||||
const EVENT_NAME = 'StartExternalVideoMsg';
|
||||
|
||||
const { meetingId, requesterUserId } = extractCredentials(this.userId);
|
||||
const { meetingId, requesterUserId: userId } = extractCredentials(this.userId);
|
||||
const { externalVideoUrl } = options;
|
||||
|
||||
check(externalVideoUrl, String);
|
||||
try {
|
||||
check(meetingId, String);
|
||||
check(userId, String);
|
||||
check(externalVideoUrl, String);
|
||||
|
||||
Meetings.update({ meetingId }, { $set: { externalVideoUrl } });
|
||||
const user = Users.findOne({ meetingId, userId, presenter: true }, { presenter: 1 });
|
||||
|
||||
const payload = { externalVideoUrl };
|
||||
if (!user) {
|
||||
Logger.error(`Only presenters are allowed to start external video for a meeting. meeting=${meetingId} userId=${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.info(`User id=${requesterUserId} sharing an external video: ${externalVideoUrl} for meeting ${meetingId}`);
|
||||
Meetings.update({ meetingId }, { $set: { externalVideoUrl } });
|
||||
|
||||
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
|
||||
const payload = { externalVideoUrl };
|
||||
|
||||
Logger.info(`User id=${userId} sharing an external video: ${externalVideoUrl} for meeting ${meetingId}`);
|
||||
|
||||
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
|
||||
} catch (error) {
|
||||
Logger.error(`Error on sharing an external video: ${externalVideoUrl} ${error}`);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
import Meetings from '/imports/api/meetings';
|
||||
import Users from '/imports/api/users';
|
||||
import RedisPubSub from '/imports/startup/server/redis';
|
||||
import { extractCredentials } from '/imports/api/common/server/helpers';
|
||||
|
||||
@ -9,19 +10,33 @@ export default function stopWatchingExternalVideo(options) {
|
||||
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
|
||||
const EVENT_NAME = 'StopExternalVideoMsg';
|
||||
|
||||
if (this.userId) {
|
||||
options = extractCredentials(this.userId);
|
||||
const { meetingId, requesterUserId } = this.userId ? extractCredentials(this.userId) : options;
|
||||
|
||||
try {
|
||||
check(meetingId, String);
|
||||
check(requesterUserId, String);
|
||||
|
||||
const user = Users.findOne({
|
||||
meetingId,
|
||||
userId: requesterUserId,
|
||||
presenter: true,
|
||||
}, { presenter: 1 });
|
||||
|
||||
if (this.userId && !user) {
|
||||
Logger.error(`Only presenters are allowed to stop external video for a meeting. meeting=${meetingId} userId=${requesterUserId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const meeting = Meetings.findOne({ meetingId });
|
||||
if (!meeting || meeting.externalVideoUrl === null) return;
|
||||
|
||||
Meetings.update({ meetingId }, { $set: { externalVideoUrl: null } });
|
||||
const payload = {};
|
||||
|
||||
Logger.info(`User id=${requesterUserId} stopped sharing an external video for meeting=${meetingId}`);
|
||||
|
||||
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
|
||||
} catch (error) {
|
||||
Logger.error(`Error on stop sharing an external video for meeting=${meetingId} ${error}`);
|
||||
}
|
||||
|
||||
const { meetingId, requesterUserId } = options;
|
||||
|
||||
const meeting = Meetings.findOne({ meetingId });
|
||||
if (!meeting || meeting.externalVideoUrl === null) return;
|
||||
|
||||
Meetings.update({ meetingId }, { $set: { externalVideoUrl: null } });
|
||||
const payload = {};
|
||||
|
||||
Logger.info(`User id=${requesterUserId} stopped sharing an external video for meeting=${meetingId}`);
|
||||
|
||||
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ import clearLocalSettings from '/imports/api/local-settings/server/modifiers/cle
|
||||
import clearRecordMeeting from './clearRecordMeeting';
|
||||
import clearVoiceCallStates from '/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates';
|
||||
import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams';
|
||||
import BannedUsers from '/imports/api/users/server/store/bannedUsers';
|
||||
|
||||
export default function meetingHasEnded(meetingId) {
|
||||
removeAnnotationsStreamer(meetingId);
|
||||
@ -46,6 +47,7 @@ export default function meetingHasEnded(meetingId) {
|
||||
clearRecordMeeting(meetingId);
|
||||
clearVoiceCallStates(meetingId);
|
||||
clearVideoStreams(meetingId);
|
||||
BannedUsers.delete(meetingId);
|
||||
|
||||
return Logger.info(`Cleared Meetings with id ${meetingId}`);
|
||||
});
|
||||
|
@ -63,15 +63,80 @@ export default class KurentoScreenshareBridge {
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
async kurentoWatchVideo() {
|
||||
static playElement(screenshareMediaElement) {
|
||||
const mediaTagPlayed = () => {
|
||||
logger.info({
|
||||
logCode: 'screenshare_media_play_success',
|
||||
}, 'Screenshare media played successfully');
|
||||
};
|
||||
|
||||
if (screenshareMediaElement.paused) {
|
||||
// Tag isn't playing yet. Play it.
|
||||
screenshareMediaElement.play()
|
||||
.then(mediaTagPlayed)
|
||||
.catch((error) => {
|
||||
// NotAllowedError equals autoplay issues, fire autoplay handling event.
|
||||
// This will be handled in the screenshare react component.
|
||||
if (error.name === 'NotAllowedError') {
|
||||
logger.error({
|
||||
logCode: 'screenshare_error_autoplay',
|
||||
extraInfo: { errorName: error.name },
|
||||
}, 'Screenshare play failed due to autoplay error');
|
||||
const tagFailedEvent = new CustomEvent('screensharePlayFailed',
|
||||
{ detail: { mediaElement: screenshareMediaElement } });
|
||||
window.dispatchEvent(tagFailedEvent);
|
||||
} else {
|
||||
// Tag failed for reasons other than autoplay. Log the error and
|
||||
// try playing again a few times until it works or fails for good
|
||||
const played = playAndRetry(screenshareMediaElement);
|
||||
if (!played) {
|
||||
logger.error({
|
||||
logCode: 'screenshare_error_media_play_failed',
|
||||
extraInfo: { errorName: error.name },
|
||||
}, `Screenshare media play failed due to ${error.name}`);
|
||||
} else {
|
||||
mediaTagPlayed();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Media tag is already playing, so log a success. This is really a
|
||||
// logging fallback for a case that shouldn't happen. But if it does
|
||||
// (ie someone re-enables the autoPlay prop in the element), then it
|
||||
// means the stream is playing properly and it'll be logged.
|
||||
mediaTagPlayed();
|
||||
}
|
||||
}
|
||||
|
||||
static screenshareElementLoadAndPlay(stream, element, muted) {
|
||||
element.muted = muted;
|
||||
element.pause();
|
||||
element.srcObject = stream;
|
||||
KurentoScreenshareBridge.playElement(element);
|
||||
}
|
||||
|
||||
kurentoViewLocalPreview() {
|
||||
const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
||||
const { webRtcPeer } = window.kurentoManager.kurentoScreenshare;
|
||||
|
||||
if (webRtcPeer) {
|
||||
const stream = webRtcPeer.getLocalStream();
|
||||
KurentoScreenshareBridge.screenshareElementLoadAndPlay(stream, screenshareMediaElement, true);
|
||||
}
|
||||
}
|
||||
|
||||
async kurentoViewScreen() {
|
||||
const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
||||
let iceServers = [];
|
||||
let started = false;
|
||||
|
||||
try {
|
||||
iceServers = await fetchWebRTCMappedStunTurnServers(getSessionToken());
|
||||
} catch (error) {
|
||||
logger.error({ logCode: 'screenshare_viwer_fetchstunturninfo_error', extraInfo: { error } },
|
||||
'Screenshare bridge failed to fetch STUN/TURN info, using default');
|
||||
logger.error({
|
||||
logCode: 'screenshare_viewer_fetchstunturninfo_error',
|
||||
extraInfo: { error },
|
||||
}, 'Screenshare bridge failed to fetch STUN/TURN info, using default');
|
||||
iceServers = getMappedFallbackStun();
|
||||
} finally {
|
||||
const options = {
|
||||
@ -81,52 +146,6 @@ export default class KurentoScreenshareBridge {
|
||||
userName: getUsername(),
|
||||
};
|
||||
|
||||
const screenshareTag = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
||||
|
||||
const playElement = () => {
|
||||
const mediaTagPlayed = () => {
|
||||
logger.info({
|
||||
logCode: 'screenshare_viewer_media_play_success',
|
||||
}, 'Screenshare viewer media played successfully');
|
||||
};
|
||||
if (screenshareTag.paused) {
|
||||
// Tag isn't playing yet. Play it.
|
||||
screenshareTag.play()
|
||||
.then(mediaTagPlayed)
|
||||
.catch((error) => {
|
||||
// NotAllowedError equals autoplay issues, fire autoplay handling event.
|
||||
// This will be handled in the screenshare react component.
|
||||
if (error.name === 'NotAllowedError') {
|
||||
logger.error({
|
||||
logCode: 'screenshare_viewer_error_autoplay',
|
||||
extraInfo: { errorName: error.name },
|
||||
}, 'Screenshare viewer play failed due to autoplay error');
|
||||
const tagFailedEvent = new CustomEvent('screensharePlayFailed',
|
||||
{ detail: { mediaElement: screenshareTag } });
|
||||
window.dispatchEvent(tagFailedEvent);
|
||||
} else {
|
||||
// Tag failed for reasons other than autoplay. Log the error and
|
||||
// try playing again a few times until it works or fails for good
|
||||
const played = playAndRetry(screenshareTag);
|
||||
if (!played) {
|
||||
logger.error({
|
||||
logCode: 'screenshare_viewer_error_media_play_failed',
|
||||
extraInfo: { errorName: error.name },
|
||||
}, `Screenshare viewer media play failed due to ${error.name}`);
|
||||
} else {
|
||||
mediaTagPlayed();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Media tag is already playing, so log a success. This is really a
|
||||
// logging fallback for a case that shouldn't happen. But if it does
|
||||
// (ie someone re-enables the autoPlay prop in the element), then it
|
||||
// means the stream is playing properly and it'll be logged.
|
||||
mediaTagPlayed();
|
||||
}
|
||||
};
|
||||
|
||||
const onFail = (error) => {
|
||||
KurentoScreenshareBridge.handleViewerFailure(error, started);
|
||||
};
|
||||
@ -139,10 +158,11 @@ export default class KurentoScreenshareBridge {
|
||||
const { webRtcPeer } = window.kurentoManager.kurentoVideo;
|
||||
if (webRtcPeer) {
|
||||
const stream = webRtcPeer.getRemoteStream();
|
||||
screenshareTag.muted = true;
|
||||
screenshareTag.pause();
|
||||
screenshareTag.srcObject = stream;
|
||||
playElement();
|
||||
KurentoScreenshareBridge.screenshareElementLoadAndPlay(
|
||||
stream,
|
||||
screenshareMediaElement,
|
||||
true,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -70,7 +70,7 @@ const currentParameters = [
|
||||
|
||||
function valueParser(val) {
|
||||
try {
|
||||
const parsedValue = JSON.parse(val.toLowerCase());
|
||||
const parsedValue = JSON.parse(val.toLowerCase().trim());
|
||||
return parsedValue;
|
||||
} catch (error) {
|
||||
logger.warn(`addUserSettings:Parameter ${val} could not be parsed (was not json)`);
|
||||
@ -87,21 +87,22 @@ export default function addUserSettings(settings) {
|
||||
|
||||
settings.forEach((el) => {
|
||||
const settingKey = Object.keys(el).shift();
|
||||
const normalizedKey = settingKey.trim();
|
||||
|
||||
if (currentParameters.includes(settingKey)) {
|
||||
if (!Object.keys(parameters).includes(settingKey)) {
|
||||
if (currentParameters.includes(normalizedKey)) {
|
||||
if (!Object.keys(parameters).includes(normalizedKey)) {
|
||||
parameters = {
|
||||
[settingKey]: valueParser(el[settingKey]),
|
||||
[normalizedKey]: valueParser(el[settingKey]),
|
||||
...parameters,
|
||||
};
|
||||
} else {
|
||||
parameters[settingKey] = el[settingKey];
|
||||
parameters[normalizedKey] = el[settingKey];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldParametersKeys.includes(settingKey)) {
|
||||
const matchingNewKey = oldParameters[settingKey];
|
||||
if (oldParametersKeys.includes(normalizedKey)) {
|
||||
const matchingNewKey = oldParameters[normalizedKey];
|
||||
if (!Object.keys(parameters).includes(matchingNewKey)) {
|
||||
parameters = {
|
||||
[matchingNewKey]: valueParser(el[settingKey]),
|
||||
@ -111,7 +112,7 @@ export default function addUserSettings(settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`Parameter ${settingKey} not handled`);
|
||||
logger.warn(`Parameter ${normalizedKey} not handled`);
|
||||
});
|
||||
|
||||
const settingsAdded = [];
|
||||
|
@ -7,7 +7,7 @@ class BannedUsers {
|
||||
}
|
||||
|
||||
init(meetingId) {
|
||||
Logger.debug('BannedUsers :: init', meetingId);
|
||||
Logger.debug('BannedUsers :: init', { meetingId });
|
||||
|
||||
if (!this.store[meetingId]) this.store[meetingId] = new Set();
|
||||
}
|
||||
@ -20,7 +20,7 @@ class BannedUsers {
|
||||
}
|
||||
|
||||
delete(meetingId) {
|
||||
Logger.debug('BannedUsers :: delete', meetingId);
|
||||
Logger.debug('BannedUsers :: delete', { meetingId });
|
||||
delete this.store[meetingId];
|
||||
}
|
||||
|
||||
|
@ -109,8 +109,9 @@ const intlMessages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const BREAKOUT_LIM = Meteor.settings.public.app.breakoutRoomLimit;
|
||||
const MIN_BREAKOUT_ROOMS = 2;
|
||||
const MAX_BREAKOUT_ROOMS = 8;
|
||||
const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS;
|
||||
|
||||
const propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
@ -117,11 +117,12 @@ input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-i
|
||||
}
|
||||
|
||||
.boxContainer {
|
||||
height: 50vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 33% 33% 33%;
|
||||
grid-template-columns: repeat(3, minmax(4rem, 16rem));
|
||||
grid-template-rows: repeat(auto-fill, minmax(4rem, 8rem));
|
||||
grid-gap: 1.5rem 1rem;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.changeToWarn {
|
||||
|
@ -261,7 +261,7 @@ class BreakoutRoom extends PureComponent {
|
||||
>
|
||||
<div className={styles.content} key={`breakoutRoomList-${breakout.breakoutId}`}>
|
||||
<span aria-hidden>
|
||||
{intl.formatMessage(intlMessages.breakoutRoom, breakout.sequence.toString())}
|
||||
{intl.formatMessage(intlMessages.breakoutRoom, { 0: breakout.sequence })}
|
||||
<span className={styles.usersAssignedNumberLabel}>
|
||||
(
|
||||
{breakout.joinedUsers.length}
|
||||
|
@ -110,7 +110,7 @@ export default withLayoutConsumer(withModalMounter(withTracker(() => {
|
||||
const { dataSaving } = Settings;
|
||||
const { viewParticipantsWebcams, viewScreenshare } = dataSaving;
|
||||
const hidePresentation = getFromUserSettings('bbb_hide_presentation', LAYOUT_CONFIG.hidePresentation);
|
||||
const autoSwapLayout = getFromUserSettings('userdata-bbb_auto_swap_layout', LAYOUT_CONFIG.autoSwapLayout);
|
||||
const autoSwapLayout = getFromUserSettings('bbb_auto_swap_layout', LAYOUT_CONFIG.autoSwapLayout);
|
||||
const { current_presentation: hasPresentation } = MediaService.getPresentationInfo();
|
||||
const data = {
|
||||
children: <DefaultContent {...{ autoSwapLayout, hidePresentation }} />,
|
||||
|
@ -11,6 +11,7 @@ import { styles } from './styles';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import Users from '/imports/api/users';
|
||||
import AudioManager from '/imports/ui/services/audio-manager';
|
||||
import { meetingIsBreakout } from '/imports/ui/components/app/service';
|
||||
|
||||
const intlMessage = defineMessages({
|
||||
410: {
|
||||
@ -129,6 +130,7 @@ class MeetingEnded extends PureComponent {
|
||||
} = this.state;
|
||||
|
||||
if (selected <= 0) {
|
||||
if (meetingIsBreakout()) window.close();
|
||||
logoutRouteHandler();
|
||||
return;
|
||||
}
|
||||
|
@ -7,18 +7,20 @@ import { tryGenerateIceCandidates } from '/imports/utils/safari-webrtc';
|
||||
import { stopWatching } from '/imports/ui/components/external-video-player/service';
|
||||
import Meetings from '/imports/api/meetings';
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
import UserListService from '/imports/ui/components/user-list/service';
|
||||
|
||||
// when the meeting information has been updated check to see if it was
|
||||
// screensharing. If it has changed either trigger a call to receive video
|
||||
// and display it, or end the call and hide the video
|
||||
const isVideoBroadcasting = () => {
|
||||
const ds = Screenshare.findOne({});
|
||||
const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID },
|
||||
{ fields: { 'screenshare.stream': 1 } });
|
||||
|
||||
if (!ds) {
|
||||
if (!screenshareEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!ds.screenshare.stream;
|
||||
return !!screenshareEntry.screenshare.stream;
|
||||
};
|
||||
|
||||
// if remote screenshare has been ended disconnect and hide the video stream
|
||||
@ -28,15 +30,21 @@ const presenterScreenshareHasEnded = () => {
|
||||
KurentoBridge.kurentoExitVideo();
|
||||
};
|
||||
|
||||
const viewScreenshare = () => {
|
||||
const amIPresenter = UserListService.isUserPresenter(Auth.userID);
|
||||
if (!amIPresenter) {
|
||||
KurentoBridge.kurentoViewScreen();
|
||||
} else {
|
||||
KurentoBridge.kurentoViewLocalPreview();
|
||||
}
|
||||
};
|
||||
|
||||
// if remote screenshare has been started connect and display the video stream
|
||||
const presenterScreenshareHasStarted = () => {
|
||||
// KurentoBridge.kurentoWatchVideo: references a function in the global
|
||||
// namespace inside kurento-extension.js that we load dynamically
|
||||
|
||||
// WebRTC restrictions may need a capture device permission to release
|
||||
// useful ICE candidates on recvonly/no-gUM peers
|
||||
tryGenerateIceCandidates().then(() => {
|
||||
KurentoBridge.kurentoWatchVideo();
|
||||
viewScreenshare();
|
||||
}).catch((error) => {
|
||||
logger.error({
|
||||
logCode: 'screenshare_no_valid_candidate_gum_failure',
|
||||
@ -46,7 +54,7 @@ const presenterScreenshareHasStarted = () => {
|
||||
},
|
||||
}, `Forced gUM to release additional ICE candidates failed due to ${error.name}.`);
|
||||
// The fallback gUM failed. Try it anyways and hope for the best.
|
||||
KurentoBridge.kurentoWatchVideo();
|
||||
viewScreenshare();
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -524,12 +524,58 @@ const requestUserInformation = (userId) => {
|
||||
makeCall('requestUserInformation', userId);
|
||||
};
|
||||
|
||||
export const getUserNamesLink = () => {
|
||||
const sortUsersByFirstName = (a, b) => {
|
||||
const aName = a.firstName.toLowerCase();
|
||||
const bName = b.firstName.toLowerCase();
|
||||
if (aName < bName) return -1;
|
||||
if (aName > bName) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortUsersByLastName = (a, b) => {
|
||||
if (a.lastName && !b.lastName) return -1;
|
||||
if (!a.lastName && b.lastName) return 1;
|
||||
|
||||
const aName = a.lastName.toLowerCase();
|
||||
const bName = b.lastName.toLowerCase();
|
||||
|
||||
if (aName < bName) return -1;
|
||||
if (aName > bName) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const isUserPresenter = (userId) => {
|
||||
const user = Users.findOne({ userId },
|
||||
{ fields: { presenter: 1 } });
|
||||
return user ? user.presenter : false;
|
||||
};
|
||||
|
||||
export const getUserNamesLink = (docTitle, fnSortedLabel, lnSortedLabel) => {
|
||||
const mimeType = 'text/plain';
|
||||
const userNamesObj = getUsers();
|
||||
const userNameListString = userNamesObj
|
||||
.map(u => u.name)
|
||||
.join('\r\n');
|
||||
const userNamesObj = getUsers()
|
||||
.map((u) => {
|
||||
const name = u.sortName.split(' ');
|
||||
return ({
|
||||
firstName: name[0],
|
||||
middleNames: name.length > 2 ? name.slice(1, name.length - 1) : null,
|
||||
lastName: name.length > 1 ? name[name.length - 1] : null,
|
||||
});
|
||||
});
|
||||
|
||||
const getUsernameString = (user) => {
|
||||
const { firstName, middleNames, lastName } = user;
|
||||
return `${firstName || ''} ${middleNames && middleNames.length > 0 ? middleNames.join(' ') : ''} ${lastName || ''}`;
|
||||
};
|
||||
|
||||
const namesByFirstName = userNamesObj.sort(sortUsersByFirstName)
|
||||
.map(u => getUsernameString(u)).join('\r\n');
|
||||
|
||||
const namesByLastName = userNamesObj.sort(sortUsersByLastName)
|
||||
.map(u => getUsernameString(u)).join('\r\n');
|
||||
|
||||
const namesListsString = `${docTitle}\r\n\r\n${fnSortedLabel}\r\n${namesByFirstName}
|
||||
\r\n\r\n${lnSortedLabel}\r\n${namesByLastName}`.replace(/ {2}/g, ' ');
|
||||
|
||||
const link = document.createElement('a');
|
||||
const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
|
||||
{ fields: { 'meetingProp.name': 1 } });
|
||||
@ -539,7 +585,7 @@ export const getUserNamesLink = () => {
|
||||
link.setAttribute('download', `bbb-${meeting.meetingProp.name}[users-list]_${dateString}.txt`);
|
||||
link.setAttribute(
|
||||
'href',
|
||||
`data: ${mimeType} ;charset=utf-16,${encodeURIComponent(userNameListString)}`,
|
||||
`data: ${mimeType} ;charset=utf-16,${encodeURIComponent(namesListsString)}`,
|
||||
);
|
||||
return link;
|
||||
};
|
||||
@ -571,4 +617,5 @@ export default {
|
||||
toggleUserLock,
|
||||
requestUserInformation,
|
||||
focusFirstDropDownItem,
|
||||
isUserPresenter,
|
||||
};
|
||||
|
@ -112,6 +112,18 @@ const intlMessages = defineMessages({
|
||||
id: 'app.actionsBar.actionsDropdown.captionsDesc',
|
||||
description: 'Captions menu toggle description',
|
||||
},
|
||||
savedNamesListTitle: {
|
||||
id: 'app.userList.userOptions.savedNames.title',
|
||||
description: '',
|
||||
},
|
||||
sortedFirstNameHeading: {
|
||||
id: 'app.userList.userOptions.sortedFirstName.heading',
|
||||
description: '',
|
||||
},
|
||||
sortedLastNameHeading: {
|
||||
id: 'app.userList.userOptions.sortedLastName.heading',
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
class UserOptions extends PureComponent {
|
||||
@ -142,7 +154,21 @@ class UserOptions extends PureComponent {
|
||||
}
|
||||
|
||||
onSaveUserNames() {
|
||||
getUserNamesLink().dispatchEvent(new MouseEvent('click',
|
||||
const { intl, meetingName } = this.props;
|
||||
const date = new Date();
|
||||
getUserNamesLink(
|
||||
intl.formatMessage(intlMessages.savedNamesListTitle,
|
||||
{
|
||||
0: meetingName,
|
||||
1: `${date.toLocaleDateString(
|
||||
document.documentElement.lang,
|
||||
)}:${date.toLocaleTimeString(
|
||||
document.documentElement.lang,
|
||||
)}`,
|
||||
}),
|
||||
intl.formatMessage(intlMessages.sortedFirstNameHeading),
|
||||
intl.formatMessage(intlMessages.sortedLastNameHeading),
|
||||
).dispatchEvent(new MouseEvent('click',
|
||||
{ bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
|
||||
|
@ -48,6 +48,13 @@ const UserOptionsContainer = withTracker((props) => {
|
||||
return muteOnStart;
|
||||
};
|
||||
|
||||
const getMeetingName = () => {
|
||||
const { meetingProp } = Meetings.findOne({ meetingId: Auth.meetingID },
|
||||
{ fields: { 'meetingProp.name': 1 } });
|
||||
const { name } = meetingProp;
|
||||
return name;
|
||||
};
|
||||
|
||||
return {
|
||||
toggleMuteAllUsers: () => {
|
||||
UserListService.muteAllUsers(Auth.userID);
|
||||
@ -78,6 +85,7 @@ const UserOptionsContainer = withTracker((props) => {
|
||||
isBreakoutRecordable: ActionsBarService.isBreakoutRecordable(),
|
||||
users: ActionsBarService.users(),
|
||||
isMeteorConnected: Meteor.status().connected,
|
||||
meetingName: getMeetingName(),
|
||||
};
|
||||
})(UserOptions);
|
||||
|
||||
|
@ -1,16 +1,16 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import _ from 'lodash';
|
||||
import VideoService from './service';
|
||||
import VideoListContainer from './video-list/container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import {
|
||||
fetchWebRTCMappedStunTurnServers,
|
||||
getMappedFallbackStun,
|
||||
} from '/imports/utils/fetchStunTurnServers';
|
||||
import { tryGenerateIceCandidates } from '/imports/utils/safari-webrtc';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import _ from 'lodash';
|
||||
|
||||
// Default values and default empty object to be backwards compat with 2.2.
|
||||
// FIXME Remove hardcoded defaults 2.3.
|
||||
@ -83,6 +83,7 @@ const propTypes = {
|
||||
isUserLocked: PropTypes.bool.isRequired,
|
||||
swapLayout: PropTypes.bool.isRequired,
|
||||
currentVideoPageIndex: PropTypes.number.isRequired,
|
||||
totalNumberOfStreams: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
class VideoProvider extends Component {
|
||||
@ -122,7 +123,7 @@ class VideoProvider extends Component {
|
||||
this.debouncedConnectStreams = _.debounce(
|
||||
this.connectStreams,
|
||||
VideoService.getPageChangeDebounceTime(),
|
||||
{ leading: false, trailing: true, }
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
}
|
||||
|
||||
@ -229,15 +230,15 @@ class VideoProvider extends Component {
|
||||
this.setState({ socketOpen: true });
|
||||
}
|
||||
|
||||
updateThreshold (numberOfPublishers) {
|
||||
updateThreshold(numberOfPublishers) {
|
||||
const { threshold, profile } = VideoService.getThreshold(numberOfPublishers);
|
||||
if (profile) {
|
||||
const publishers = Object.values(this.webRtcPeers)
|
||||
.filter(peer => peer.isPublisher)
|
||||
.forEach(peer => {
|
||||
.forEach((peer) => {
|
||||
// 0 means no threshold in place. Reapply original one if needed
|
||||
let profileToApply = (threshold === 0) ? peer.originalProfileId : profile;
|
||||
VideoService.applyCameraProfile(peer, profileToApply)
|
||||
const profileToApply = (threshold === 0) ? peer.originalProfileId : profile;
|
||||
VideoService.applyCameraProfile(peer, profileToApply);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -271,7 +272,7 @@ class VideoProvider extends Component {
|
||||
updateStreams(streams, shouldDebounce = false) {
|
||||
const [streamsToConnect, streamsToDisconnect] = this.getStreamsToConnectAndDisconnect(streams);
|
||||
|
||||
if(shouldDebounce) {
|
||||
if (shouldDebounce) {
|
||||
this.debouncedConnectStreams(streamsToConnect);
|
||||
} else {
|
||||
this.connectStreams(streamsToConnect);
|
||||
@ -679,7 +680,7 @@ class VideoProvider extends Component {
|
||||
|
||||
this.restartTimeout[cameraId] = setTimeout(
|
||||
this._getWebRTCStartTimeout(cameraId, isLocal),
|
||||
this.restartTimer[cameraId]
|
||||
this.restartTimer[cameraId],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -879,13 +880,8 @@ class VideoProvider extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { swapLayout, currentVideoPageIndex } = this.props;
|
||||
const { socketOpen } = this.state;
|
||||
if (!socketOpen) return null;
|
||||
const { swapLayout, currentVideoPageIndex, streams } = this.props;
|
||||
|
||||
const {
|
||||
streams,
|
||||
} = this.props;
|
||||
return (
|
||||
<VideoListContainer
|
||||
streams={streams}
|
||||
|
@ -34,17 +34,11 @@ const {
|
||||
const TOKEN = '_';
|
||||
|
||||
class VideoService {
|
||||
static isUserPresenter(userId) {
|
||||
const user = Users.findOne({ userId },
|
||||
{ fields: { presenter: 1 } });
|
||||
return user ? user.presenter : false;
|
||||
}
|
||||
|
||||
// Paginated streams: sort with following priority: local -> presenter -> alphabetic
|
||||
static sortPaginatedStreams(s1, s2) {
|
||||
if (VideoService.isUserPresenter(s1.userId) && !VideoService.isUserPresenter(s2.userId)) {
|
||||
if (UserListService.isUserPresenter(s1.userId) && !UserListService.isUserPresenter(s2.userId)) {
|
||||
return -1;
|
||||
} else if (VideoService.isUserPresenter(s2.userId) && !VideoService.isUserPresenter(s1.userId)) {
|
||||
} else if (UserListService.isUserPresenter(s2.userId) && !UserListService.isUserPresenter(s1.userId)) {
|
||||
return 1;
|
||||
} else {
|
||||
return UserListService.sortUsersByName(s1, s2);
|
||||
@ -53,8 +47,10 @@ class VideoService {
|
||||
|
||||
// Full mesh: sort with the following priority: local -> alphabetic
|
||||
static sortMeshStreams(s1, s2) {
|
||||
if (s1.userId === Auth.userID) {
|
||||
if (s1.userId === Auth.userID && s2.userId !== Auth.userID) {
|
||||
return -1;
|
||||
} else if (s2.userId === Auth.userID && s1.userId !== Auth.userID) {
|
||||
return 1;
|
||||
} else {
|
||||
return UserListService.sortUsersByName(s1, s2);
|
||||
}
|
||||
@ -546,10 +542,17 @@ class VideoService {
|
||||
this.exitVideo();
|
||||
}
|
||||
|
||||
isDisabled() {
|
||||
disableReason() {
|
||||
const { viewParticipantsWebcams } = Settings.dataSaving;
|
||||
|
||||
return this.isUserLocked() || this.isConnecting || !viewParticipantsWebcams;
|
||||
const locks = {
|
||||
videoLocked: this.isUserLocked(),
|
||||
videoConnecting: this.isConnecting,
|
||||
dataSaving: !viewParticipantsWebcams,
|
||||
meteorDisconnected: !Meteor.status().connected
|
||||
};
|
||||
const locksKeys = Object.keys(locks);
|
||||
const disableReason = locksKeys.filter( i => locks[i]).shift();
|
||||
return disableReason ? disableReason : false;
|
||||
}
|
||||
|
||||
getRole(isLocal) {
|
||||
@ -739,7 +742,7 @@ export default {
|
||||
getAuthenticatedURL: () => videoService.getAuthenticatedURL(),
|
||||
isLocalStream: cameraId => videoService.isLocalStream(cameraId),
|
||||
hasVideoStream: () => videoService.hasVideoStream(),
|
||||
isDisabled: () => videoService.isDisabled(),
|
||||
disableReason: () => videoService.disableReason(),
|
||||
playStart: cameraId => videoService.playStart(cameraId),
|
||||
getCameraProfile: () => videoService.getCameraProfile(),
|
||||
addCandidateToPeer: (peer, candidate, cameraId) => videoService.addCandidateToPeer(peer, candidate, cameraId),
|
||||
|
@ -24,6 +24,18 @@ const intlMessages = defineMessages({
|
||||
id: 'app.video.videoLocked',
|
||||
description: 'video disabled label',
|
||||
},
|
||||
videoConnecting: {
|
||||
id: 'app.video.connecting',
|
||||
description: 'video connecting label',
|
||||
},
|
||||
dataSaving: {
|
||||
id: 'app.video.dataSaving',
|
||||
description: 'video data saving label',
|
||||
},
|
||||
meteorDisconnected: {
|
||||
id: 'app.video.clientDisconnected',
|
||||
description: 'Meteor disconnected label',
|
||||
},
|
||||
iOSWarning: {
|
||||
id: 'app.iOSWarning.label',
|
||||
description: 'message indicating to upgrade ios version',
|
||||
@ -33,14 +45,13 @@ const intlMessages = defineMessages({
|
||||
const propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
hasVideoStream: PropTypes.bool.isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
mountVideoPreview: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const JoinVideoButton = ({
|
||||
intl,
|
||||
hasVideoStream,
|
||||
isDisabled,
|
||||
disableReason,
|
||||
mountVideoPreview,
|
||||
}) => {
|
||||
const exitVideo = () => hasVideoStream && !VideoService.isMultipleCamerasEnabled();
|
||||
@ -57,14 +68,14 @@ const JoinVideoButton = ({
|
||||
}
|
||||
};
|
||||
|
||||
const label = exitVideo() ?
|
||||
intl.formatMessage(intlMessages.leaveVideo) :
|
||||
intl.formatMessage(intlMessages.joinVideo);
|
||||
const label = exitVideo()
|
||||
? intl.formatMessage(intlMessages.leaveVideo)
|
||||
: intl.formatMessage(intlMessages.joinVideo);
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-test="joinVideo"
|
||||
label={isDisabled ? intl.formatMessage(intlMessages.videoLocked) : label}
|
||||
label={disableReason ? intl.formatMessage(intlMessages[disableReason]) : label}
|
||||
className={cx(styles.button, hasVideoStream || styles.btn)}
|
||||
onClick={handleOnClick}
|
||||
hideLabel
|
||||
@ -74,7 +85,7 @@ const JoinVideoButton = ({
|
||||
ghost={!hasVideoStream}
|
||||
size="lg"
|
||||
circle
|
||||
disabled={isDisabled}
|
||||
disabled={!!disableReason}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ import VideoService from '../service';
|
||||
const JoinVideoOptionsContainer = (props) => {
|
||||
const {
|
||||
hasVideoStream,
|
||||
isDisabled,
|
||||
disableReason,
|
||||
intl,
|
||||
mountModal,
|
||||
...restProps
|
||||
@ -19,7 +19,7 @@ const JoinVideoOptionsContainer = (props) => {
|
||||
|
||||
return (
|
||||
<JoinVideoButton {...{
|
||||
mountVideoPreview, hasVideoStream, isDisabled, ...restProps,
|
||||
mountVideoPreview, hasVideoStream, disableReason, ...restProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -27,5 +27,5 @@ const JoinVideoOptionsContainer = (props) => {
|
||||
|
||||
export default withModalMounter(injectIntl(withTracker(() => ({
|
||||
hasVideoStream: VideoService.hasVideoStream(),
|
||||
isDisabled: VideoService.isDisabled() || !Meteor.status().connected,
|
||||
disableReason: VideoService.disableReason(),
|
||||
}))(JoinVideoOptionsContainer)));
|
||||
|
@ -280,6 +280,31 @@ class AudioManager {
|
||||
return this.bridge.transferCall(this.onAudioJoin.bind(this));
|
||||
}
|
||||
|
||||
onVoiceUserChanges(fields) {
|
||||
if (fields.muted !== undefined && fields.muted !== this.isMuted) {
|
||||
let muteState;
|
||||
this.isMuted = fields.muted;
|
||||
|
||||
if (this.isMuted) {
|
||||
muteState = 'selfMuted';
|
||||
this.mute();
|
||||
} else {
|
||||
muteState = 'selfUnmuted';
|
||||
this.unmute();
|
||||
}
|
||||
|
||||
window.parent.postMessage({ response: muteState }, '*');
|
||||
}
|
||||
|
||||
if (fields.talking !== undefined && fields.talking !== this.isTalking) {
|
||||
this.isTalking = fields.talking;
|
||||
}
|
||||
|
||||
if (this.isMuted) {
|
||||
this.isTalking = false;
|
||||
}
|
||||
}
|
||||
|
||||
onAudioJoin() {
|
||||
this.isConnecting = false;
|
||||
this.isConnected = true;
|
||||
@ -288,21 +313,8 @@ class AudioManager {
|
||||
if (!this.muteHandle) {
|
||||
const query = VoiceUsers.find({ intId: Auth.userID }, { fields: { muted: 1, talking: 1 } });
|
||||
this.muteHandle = query.observeChanges({
|
||||
changed: (id, fields) => {
|
||||
if (fields.muted !== undefined && fields.muted !== this.isMuted) {
|
||||
this.isMuted = fields.muted;
|
||||
const muteState = this.isMuted ? 'selfMuted' : 'selfUnmuted';
|
||||
window.parent.postMessage({ response: muteState }, '*');
|
||||
}
|
||||
|
||||
if (fields.talking !== undefined && fields.talking !== this.isTalking) {
|
||||
this.isTalking = fields.talking;
|
||||
}
|
||||
|
||||
if (this.isMuted) {
|
||||
this.isTalking = false;
|
||||
}
|
||||
},
|
||||
added: (id, fields) => this.onVoiceUserChanges(fields),
|
||||
changed: (id, fields) => this.onVoiceUserChanges(fields),
|
||||
});
|
||||
}
|
||||
|
||||
@ -562,6 +574,29 @@ class AudioManager {
|
||||
this.autoplayBlocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
setSenderTrackEnabled(shouldEnable) {
|
||||
// If the bridge is set to listen only mode, nothing to do here. This method
|
||||
// is solely for muting outbound tracks.
|
||||
if (this.isListenOnly) return;
|
||||
|
||||
// Bridge -> SIP.js bridge, the only full audio capable one right now
|
||||
const peer = this.bridge.getPeerConnection();
|
||||
peer.getSenders().forEach((sender) => {
|
||||
const { track } = sender;
|
||||
if (track && track.kind === 'audio') {
|
||||
track.enabled = shouldEnable;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mute() {
|
||||
this.setSenderTrackEnabled(false);
|
||||
}
|
||||
|
||||
unmute() {
|
||||
this.setSenderTrackEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
const audioManager = new AudioManager();
|
||||
|
@ -35,6 +35,10 @@ public:
|
||||
duration: 4000
|
||||
remainingTimeThreshold: 30
|
||||
remainingTimeAlertThreshold: 1
|
||||
# Warning: increasing the limit of breakout rooms per meeting
|
||||
# can generate excessive overhead to the server. We recommend
|
||||
# this value to be kept under 12.
|
||||
breakoutRoomLimit: 8
|
||||
defaultSettings:
|
||||
application:
|
||||
animations: true
|
||||
|
@ -113,6 +113,9 @@
|
||||
"app.userList.userOptions.enableNote": "Shared notes are now enabled",
|
||||
"app.userList.userOptions.showUserList": "User list is now shown to viewers",
|
||||
"app.userList.userOptions.enableOnlyModeratorWebcam": "You can enable your webcam now, everyone will see you",
|
||||
"app.userList.userOptions.savedNames.title": "List of users in meeting {0} at {1}",
|
||||
"app.userList.userOptions.sortedFirstName.heading": "Sorted by first name:",
|
||||
"app.userList.userOptions.sortedLastName.heading": "Sorted by last name:",
|
||||
"app.media.label": "Media",
|
||||
"app.media.autoplayAlertDesc": "Allow Access",
|
||||
"app.media.screenshare.start": "Screenshare has started",
|
||||
@ -587,6 +590,8 @@
|
||||
"app.videoPreview.webcamNotFoundLabel": "Webcam not found",
|
||||
"app.videoPreview.profileNotFoundLabel": "No supported camera profile",
|
||||
"app.video.joinVideo": "Share webcam",
|
||||
"app.video.connecting": "Webcam sharing is starting ...",
|
||||
"app.video.dataSaving": "Webcam sharing is disabled in Data Saving",
|
||||
"app.video.leaveVideo": "Stop sharing webcam",
|
||||
"app.video.iceCandidateError": "Error on adding ICE candidate",
|
||||
"app.video.iceConnectionStateError": "Connection failure (ICE error 1107)",
|
||||
@ -612,6 +617,7 @@
|
||||
"app.video.chromeExtensionErrorLink": "this Chrome extension",
|
||||
"app.video.pagination.prevPage": "See previous videos",
|
||||
"app.video.pagination.nextPage": "See next videos",
|
||||
"app.video.clientDisconnected": "Webcam cannot be shared due to connection issues",
|
||||
"app.fullscreenButton.label": "Make {0} fullscreen",
|
||||
"app.deskshare.iceConnectionStateError": "Connection failed when sharing screen (ICE error 1108)",
|
||||
"app.sfu.mediaServerConnectionError2000": "Unable to connect to media server (error 2000)",
|
||||
|
@ -241,10 +241,10 @@ defaultClientUrl=${bigbluebutton.web.serverURL}/client/BigBlueButton.html
|
||||
allowRequestsWithoutSession=false
|
||||
|
||||
# Force all attendees to join the meeting using the HTML5 client
|
||||
attendeesJoinViaHTML5Client=false
|
||||
attendeesJoinViaHTML5Client=true
|
||||
|
||||
# Force all moderators to join the meeting using the HTML5 client
|
||||
moderatorsJoinViaHTML5Client=false
|
||||
moderatorsJoinViaHTML5Client=true
|
||||
|
||||
# The url of the BigBlueButton HTML5 client. Users will be redirected here when
|
||||
# successfully joining the meeting.
|
||||
@ -354,3 +354,8 @@ lockSettingsLockOnJoinConfigurable=false
|
||||
allowDuplicateExtUserid=true
|
||||
|
||||
defaultTextTrackUrl=${bigbluebutton.web.serverURL}/bigbluebutton
|
||||
|
||||
# Param to end the meeting when there are no moderators after a certain period of time.
|
||||
# Needed for classes where teacher gets disconnected and can't get back in. Prevents
|
||||
# students from running amok.
|
||||
endWhenNoModerator=false
|
||||
|
@ -158,6 +158,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
<property name="lockSettingsLockOnJoin" value="${lockSettingsLockOnJoin}"/>
|
||||
<property name="lockSettingsLockOnJoinConfigurable" value="${lockSettingsLockOnJoinConfigurable}"/>
|
||||
<property name="allowDuplicateExtUserid" value="${allowDuplicateExtUserid}"/>
|
||||
<property name="endWhenNoModerator" value="${endWhenNoModerator}"/>
|
||||
</bean>
|
||||
|
||||
<import resource="doc-conversion.xml"/>
|
||||
|
Loading…
Reference in New Issue
Block a user