Merge branch 'v2.6.x-release' into presentation-test-impr

This commit is contained in:
Anton B 2023-04-10 15:50:49 -03:00
commit 659c4030dd
133 changed files with 2025 additions and 574 deletions

View File

@ -1 +1 @@
git clone --branch v2.9.8 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu
git clone --branch v2.9.10 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu

View File

@ -21,6 +21,7 @@ const SFU_URL = Meteor.settings.public.kurento.wsUrl;
const DEFAULT_LISTENONLY_MEDIA_SERVER = Meteor.settings.public.kurento.listenOnlyMediaServer;
const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
const MEDIA = Meteor.settings.public.media;
const DEFAULT_FULLAUDIO_MEDIA_SERVER = MEDIA.audio.fullAudioMediaServer;
const LISTEN_ONLY_OFFERING = MEDIA.listenOnlyOffering;
@ -265,6 +266,7 @@ export default class SFUAudioBridge extends BaseAudioBridge {
traceLogs: TRACE_LOGS,
networkPriority: NETWORK_PRIORITY,
mediaStreamFactory: this.mediaStreamFactory,
gatheringTimeout: GATHERING_TIMEOUT,
};
this.broker = new AudioBroker(
@ -352,6 +354,7 @@ export default class SFUAudioBridge extends BaseAudioBridge {
iceServers,
offering: LISTEN_ONLY_OFFERING,
traceLogs: TRACE_LOGS,
gatheringTimeout: GATHERING_TIMEOUT,
};
this.broker = new AudioBroker(

View File

@ -6,6 +6,7 @@ import { setSharingScreen, screenShareEndAlert } from '/imports/ui/components/sc
import { SCREENSHARING_ERRORS } from './errors';
import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils';
import MediaStreamUtils from '/imports/utils/media-stream-utils';
import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
const SFU_CONFIG = Meteor.settings.public.kurento;
const SFU_URL = SFU_CONFIG.wsUrl;
@ -13,6 +14,7 @@ const OFFERING = SFU_CONFIG.screenshare.subscriberOffering;
const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
const { screenshare: NETWORK_PRIORITY } = Meteor.settings.public.media.networkPriorities || {};
const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
const BRIDGE_NAME = 'kurento'
const SCREENSHARE_VIDEO_TAG = 'screenshareVideo';
@ -51,6 +53,7 @@ export default class KurentoScreenshareBridge {
this.reconnecting = false;
this.reconnectionTimeout;
this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT;
this.startedOnce = false;
}
get gdmStream() {
@ -63,7 +66,7 @@ export default class KurentoScreenshareBridge {
_shouldReconnect() {
// Sender/presenter reconnect is *not* implemented yet
return this.broker.started && this.role === RECV_ROLE;
return this.reconnectionTimeout == null && this.role === RECV_ROLE;
}
/**
@ -146,9 +149,12 @@ export default class KurentoScreenshareBridge {
return this.connectionAttempts > BridgeService.MAX_CONN_ATTEMPTS;
}
scheduleReconnect(immediate = false) {
scheduleReconnect({
overrideTimeout,
} = { }) {
if (this.reconnectionTimeout == null) {
const nextRestartInterval = immediate ? 0 : this.restartIntervalMs;
let nextRestartInterval = this.restartIntervalMs;
if (typeof overrideTimeout === 'number') nextRestartInterval = overrideTimeout;
this.reconnectionTimeout = setTimeout(
this.handleConnectionTimeoutExpiry.bind(this),
@ -197,6 +203,7 @@ export default class KurentoScreenshareBridge {
BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, !this.broker.hasAudio);
}
this.startedOnce = true;
this.clearReconnectionTimeout();
this.connectionAttempts = 0;
}
@ -208,21 +215,31 @@ export default class KurentoScreenshareBridge {
logger.error({
logCode: 'screenshare_broker_failure',
extraInfo: {
errorCode, errorMessage,
errorCode,
errorMessage,
role: this.broker.role,
started: this.broker.started,
reconnecting: this.reconnecting,
bridge: BRIDGE_NAME
bridge: BRIDGE_NAME,
},
}, `Screenshare broker failure: ${errorMessage}`);
notifyStreamStateChange('screenshare', 'failed');
// Screensharing was already successfully negotiated and error occurred during
// during call; schedule a reconnect
// If the session has not yet started, a reconnect should already be scheduled
if (this._shouldReconnect()) {
// this.broker.started => whether the reconnect should happen immediately.
// If this session had alredy been established, it should.
this.scheduleReconnect(this.broker.started);
// If this session previously established connection (N-sessions back)
// and it failed abruptly, then the timeout is overridden to a intermediate value
// (BASE_RECONNECTION_TIMEOUT)
let overrideTimeout;
if (this.broker?.started) {
overrideTimeout = 0;
} else if (this.startedOnce) {
overrideTimeout = BridgeService.BASE_RECONNECTION_TIMEOUT;
}
this.scheduleReconnect({ overrideTimeout });
}
return error;
@ -241,6 +258,7 @@ export default class KurentoScreenshareBridge {
signalCandidates: SIGNAL_CANDIDATES,
forceRelay: shouldForceRelay(),
traceLogs: TRACE_LOGS,
gatheringTimeout: GATHERING_TIMEOUT,
};
this.broker = new ScreenshareBroker(
@ -264,6 +282,7 @@ export default class KurentoScreenshareBridge {
logCode: 'screenshare_presenter_start_success',
}, 'Screenshare presenter started succesfully');
this.clearReconnectionTimeout();
this.startedOnce = true;
this.reconnecting = false;
this.connectionAttempts = 0;
}
@ -309,6 +328,7 @@ export default class KurentoScreenshareBridge {
forceRelay: shouldForceRelay(),
traceLogs: TRACE_LOGS,
networkPriority: NETWORK_PRIORITY,
gatheringTimeout: GATHERING_TIMEOUT,
};
this.broker = new ScreenshareBroker(

View File

@ -16,6 +16,7 @@ const {
maxTimeout: MAX_MEDIA_TIMEOUT,
maxConnectionAttempts: MAX_CONN_ATTEMPTS,
timeoutIncreaseFactor: TIMEOUT_INCREASE_FACTOR,
baseReconnectionTimeout: BASE_RECONNECTION_TIMEOUT,
} = MEDIA_TIMEOUTS;
const HAS_DISPLAY_MEDIA = (typeof navigator.getDisplayMedia === 'function'
@ -111,7 +112,7 @@ const getMediaServerAdapter = () => {
const getNextReconnectionInterval = (oldInterval) => {
return Math.min(
TIMEOUT_INCREASE_FACTOR * oldInterval,
(TIMEOUT_INCREASE_FACTOR * Math.max(oldInterval, BASE_RECONNECTION_TIMEOUT)),
MAX_MEDIA_TIMEOUT,
);
}
@ -157,6 +158,7 @@ export default {
screenshareLoadAndPlayMediaStream,
getMediaServerAdapter,
BASE_MEDIA_TIMEOUT,
BASE_RECONNECTION_TIMEOUT,
MAX_CONN_ATTEMPTS,
BASE_BITRATE,
};

View File

@ -34,15 +34,18 @@ export default function zoomSlide(slideNumber, podId, widthRatio, heightRatio, x
throw new Meteor.Error('presentation-not-found', 'You need a presentation to be able to switch slides');
}
let validSlideNum = slideNumber;
if (validSlideNum > Presentation?.pages?.length) validSlideNum = 1;
const Slide = Slides.findOne({
meetingId,
podId,
presentationId: Presentation.id,
num: slideNumber,
num: validSlideNum,
});
if (!Slide) {
throw new Meteor.Error('slide-not-found', `Slide number ${slideNumber} not found in the current presentation`);
throw new Meteor.Error('slide-not-found', `Slide number ${validSlideNum} not found in the current presentation`);
}
const payload = {

View File

@ -246,8 +246,6 @@ class Base extends Component {
}
renderByState() {
const { updateLoadingState } = this;
const stateControls = { updateLoadingState };
const { loading, userRemoved } = this.state;
const {
codeError,
@ -301,7 +299,7 @@ class Base extends Component {
return (<MeetingEnded code={codeError} callback={() => Base.setExitReason('logout')} />);
}
return (<AppContainer {...this.props} baseControls={stateControls} />);
return (<AppContainer {...this.props} />);
}
render() {

View File

@ -125,7 +125,7 @@ const ScreenshareButton = ({
const handleFailure = (error) => {
const {
errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode,
errorMessage,
errorMessage = error.message,
} = error;
const localizedError = getErrorLocale(errorCode);

View File

@ -1,6 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { defineMessages, injectIntl } from 'react-intl';
import Auth from '/imports/ui/services/auth';
import Users from '/imports/api/users';
import Meetings, { LayoutMeetings } from '/imports/api/meetings';
@ -38,13 +37,6 @@ import App from './component';
const CUSTOM_STYLE_URL = Meteor.settings.public.app.customStyleUrl;
const intlMessages = defineMessages({
waitingApprovalMessage: {
id: 'app.guest.waiting',
description: 'Message while a guest is waiting to be approved',
},
});
const endMeeting = (code, ejectedReason) => {
Session.set('codeError', code);
Session.set('errorMessageDescription', ejectedReason);
@ -204,7 +196,7 @@ const currentUserEmoji = (currentUser) => (currentUser
}
);
export default injectIntl(withModalMounter(withTracker(({ intl, baseControls }) => {
export default withModalMounter(withTracker(() => {
Users.find({ userId: Auth.userID, meetingId: Auth.meetingID }).observe({
removed(userData) {
// wait 3secs (before endMeeting), client will try to authenticate again
@ -260,10 +252,6 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
presentationVideoRate: meetingLayoutVideoRate,
} = meetingLayoutObj;
if (currentUser && !currentUser.approved) {
baseControls.updateLoadingState(intl.formatMessage(intlMessages.waitingApprovalMessage));
}
const UserInfo = UserInfos.find({
meetingId: Auth.meetingID,
requesterUserId: Auth.userID,
@ -330,4 +318,4 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
isModalOpen: !!getModal(),
ignorePollNotifications: Session.get('ignorePollNotifications'),
};
})(AppContainer)));
})(AppContainer));

View File

@ -11,6 +11,8 @@ const {
let audioContext = null;
let sourceContext = null;
let contextDestination = null;
let stubAudioElement = null;
let delayNode = null;
const useRTCLoopback = () => (browserInfo.isChrome || browserInfo.isEdge) && USE_RTC_LOOPBACK_CHR;
@ -31,20 +33,45 @@ const cleanupDelayNode = () => {
audioContext.close();
audioContext = null;
}
if (contextDestination) {
contextDestination.disconnect();
contextDestination = null;
}
if (stubAudioElement) {
stubAudioElement.pause();
stubAudioElement.srcObject = null;
stubAudioElement = null;
}
};
const addDelayNode = (stream) => {
if (stream) {
if (delayNode || audioContext || sourceContext) cleanupDelayNode();
const audioElement = document.querySelector(MEDIA_TAG);
// Workaround: attach the stream to a muted stub audio element to be able to play it in
// Chromium-based browsers. See https://bugs.chromium.org/p/chromium/issues/detail?id=933677
stubAudioElement = new Audio();
stubAudioElement.muted = true;
stubAudioElement.srcObject = stream;
// Create a new AudioContext to be able to add a delay to the stream
audioContext = new AudioContext();
sourceContext = audioContext.createMediaStreamSource(stream);
contextDestination = audioContext.createMediaStreamDestination();
// Create a DelayNode to add a delay to the stream
delayNode = new DelayNode(audioContext, { delayTime, maxDelayTime });
// Connect the stream to the DelayNode and then to the MediaStreamDestinationNode
// to be able to play the stream.
sourceContext.connect(delayNode);
delayNode.connect(audioContext.destination);
delayNode.connect(contextDestination);
delayNode.delayTime.setValueAtTime(delayTime, audioContext.currentTime);
// Play the stream with the delay in the default audio element (remote-media)
audioElement.srcObject = contextDestination.stream;
}
};
const deattachEchoStream = () => {
const audioElement = document.querySelector(MEDIA_TAG);
@ -59,11 +86,12 @@ const deattachEchoStream = () => {
const playEchoStream = async (stream, loopbackAgent = null) => {
if (stream) {
const audioElement = document.querySelector(MEDIA_TAG);
deattachEchoStream();
let streamToPlay = stream;
if (loopbackAgent) {
// Chromium based browsers need audio to go through PCs for echo cancellation
// to work. See https://bugs.chromium.org/p/chromium/issues/detail?id=687574
try {
await loopbackAgent.start(stream);
streamToPlay = loopbackAgent.loopbackStream;
@ -73,12 +101,15 @@ const playEchoStream = async (stream, loopbackAgent = null) => {
}
if (DELAY_ENABLED) {
// Start muted to avoid weird artifacts and prevent playing the stream twice (Chromium)
audioElement.muted = true;
addDelayNode(streamToPlay);
} else {
// No delay: play the stream in the default audio element (remote-media),
// no strings attached.
const audioElement = document.querySelector(MEDIA_TAG);
audioElement.srcObject = streamToPlay;
audioElement.muted = false;
audioElement.play();
}
audioElement.srcObject = streamToPlay;
audioElement.play();
}
};

View File

@ -10,10 +10,10 @@ import { btnSpacing } from '/imports/ui/stylesheets/styled-components/general';
const EmojiButtonIcon = styled(Icon)`
position: absolute;
top: 0;
height: 60%;
height: 40%;
left: 0;
width: 75%;
margin-left: 15%;
margin-left: 14%;
font-size: 50%;
margin-top: 30%;
color: ${btnDefaultColor};

View File

@ -98,7 +98,7 @@ const FullscreenButtonComponent = ({
label={formattedLabel(isFullscreen)}
hideLabel
isStyled={fullScreenStyle}
data-test="presentationFullscreenButton"
data-test="webcamFullscreenButton"
/>
</Styled.FullscreenButtonWrapper>
);

View File

@ -6,6 +6,7 @@ import Menu from "@material-ui/core/Menu";
import { Divider } from "@material-ui/core";
import Icon from "/imports/ui/components/common/icon/component";
import { SMALL_VIEWPORT_BREAKPOINT } from '/imports/ui/components/layout/enums';
import KEY_CODES from '/imports/utils/keyCodes';
import { ENTER } from "/imports/utils/keyCodes";
@ -32,19 +33,52 @@ class BBBMenu extends React.Component {
this.optsToMerge = {};
this.autoFocus = false;
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleClose = this.handleClose.bind(this);
}
componentDidUpdate() {
const { anchorEl } = this.state;
if (this.props.open === false && anchorEl) {
const { open } = this.props;
if (open === false && anchorEl) {
this.setState({ anchorEl: null });
} else if (this.props.open === true && !anchorEl) {
} else if (open === true && !anchorEl) {
this.setState({ anchorEl: this.anchorElRef });
}
}
handleKeyDown(event) {
const { anchorEl } = this.state;
const isMenuOpen = Boolean(anchorEl);
if ([KEY_CODES.ESCAPE, KEY_CODES.TAB].includes(event.which)) {
this.handleClose();
return;
}
if (isMenuOpen && [KEY_CODES.ARROW_UP, KEY_CODES.ARROW_DOWN].includes(event.which)) {
event.preventDefault();
event.stopPropagation();
const menuItems = Array.from(document.querySelectorAll('[data-key^="menuItem-"]'));
if (menuItems.length === 0) return;
const focusedIndex = menuItems.findIndex(item => item === document.activeElement);
const nextIndex = event.which === KEY_CODES.ARROW_UP ? focusedIndex - 1 : focusedIndex + 1;
let indexToFocus = 0;
if (nextIndex < 0) {
indexToFocus = menuItems.length - 1;
} else if (nextIndex >= menuItems.length) {
indexToFocus = 0;
} else {
indexToFocus = nextIndex;
}
menuItems[indexToFocus].focus();
}
};
handleClick(event) {
this.setState({ anchorEl: event.currentTarget });
};
@ -90,6 +124,7 @@ class BBBMenu extends React.Component {
emoji={emojiSelected ? 'yes' : 'no'}
key={label}
data-test={dataTest}
data-key={`menuItem-${dataTest}`}
disableRipple={true}
disableGutters={true}
disabled={disabled}
@ -115,7 +150,7 @@ class BBBMenu extends React.Component {
render() {
const { anchorEl } = this.state;
const { trigger, intl, customStyles, dataTest, opts } = this.props;
const { trigger, intl, customStyles, dataTest, opts, accessKey } = this.props;
const actionsItems = this.makeMenuItems();
let menuStyles = { zIndex: 9999 };
@ -140,7 +175,7 @@ class BBBMenu extends React.Component {
if (e.which !== ENTER) return null;
this.handleClick(e);
}}
accessKey={this.props?.accessKey}
accessKey={accessKey}
ref={(ref) => this.anchorElRef = ref}
>
{trigger}
@ -154,6 +189,7 @@ class BBBMenu extends React.Component {
onClose={this.handleClose}
style={menuStyles}
data-test={dataTest}
onKeyDownCapture={this.handleKeyDown}
>
{actionsItems}
{anchorEl && window.innerWidth < SMALL_VIEWPORT_BREAKPOINT &&
@ -204,6 +240,7 @@ BBBMenu.propTypes = {
divider: PropTypes.bool,
dividerTop: PropTypes.bool,
accessKey: PropTypes.string,
dataTest: PropTypes.string,
})).isRequired,
onCloseCallback: PropTypes.func,

View File

@ -9,3 +9,9 @@
fill: #333333;
background-color: transparent;
}
.tippy-tooltip.bbbtip-theme>.tippy-content{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -452,7 +452,7 @@ class ConnectionStatusComponent extends PureComponent {
</Styled.NetworkData>
<Styled.NetworkData>
<div>{`${videoUploadLabel}`}</div>
<div>{`${videoCurrentUploadRate}k ↑`}</div>
<div data-test="videoUploadRateData">{`${videoCurrentUploadRate}k ↑`}</div>
</Styled.NetworkData>
<Styled.NetworkData>
<div>{`${intl.formatMessage(intlMessages.jitter)}`}</div>

View File

@ -79,7 +79,7 @@ export const ACTIONS = {
SET_PRESENTATION_IS_OPEN: 'setPresentationIsOpen',
SET_PRESENTATION_CURRENT_SLIDE_SIZE: 'setPresentationCurrentSlideSize',
SET_PRESENTATION_NUM_CURRENT_SLIDE: 'setPresentationNumCurrentSlide',
SET_PRESENTATION_SLIDES_LENGTH: 'setPresentationSlideslength',
SET_PRESENTATION_SLIDES_LENGTH: 'setPresentationSlidesLength',
SET_PRESENTATION_SIZE: 'setPresentationSize',
SET_PRESENTATION_OUTPUT: 'setPresentationOutput',
SET_PRESENTATION_IS_RESIZABLE: 'setPresentationIsResizable',

View File

@ -5,6 +5,7 @@ import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState';
import { ACTIONS, CAMERADOCK_POSITION, PANELS } from '../enums';
import Storage from '/imports/ui/services/storage/session';
import { isPresentationEnabled } from '/imports/ui/services/features';
const windowWidth = () => window.document.documentElement.clientWidth;
const windowHeight = () => window.document.documentElement.clientHeight;
@ -30,6 +31,10 @@ const CustomLayout = (props) => {
const currentPanelType = layoutSelect((i) => i.currentPanelType);
const presentationInput = layoutSelectInput((i) => i.presentation);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
@ -207,14 +212,21 @@ const CustomLayout = (props) => {
};
const calculatesSidebarContentHeight = (cameraDockHeight) => {
const { isOpen } = presentationInput;
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const hasPresentation = isPresentationEnabled() && slidesLength !== 0
const isGeneralMediaOff = !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
let sidebarContentHeight = 0;
if (sidebarContentInput.isOpen) {
if (isMobile) {
sidebarContentHeight = windowHeight() - DEFAULT_VALUES.navBarHeight;
} else if (cameraDockInput.numCameras > 0
&& cameraDockInput.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM
&& isOpen) {
&& isOpen && !isGeneralMediaOff) {
sidebarContentHeight = windowHeight() - cameraDockHeight;
} else {
sidebarContentHeight = windowHeight();
@ -361,7 +373,7 @@ const CustomLayout = (props) => {
);
}
cameraDockBounds.top = windowHeight() - cameraDockHeight;
cameraDockBounds.top = windowHeight() - cameraDockHeight - bannerAreaHeight();
cameraDockBounds.left = !isRTL ? sidebarNavWidth : 0;
cameraDockBounds.right = isRTL ? sidebarNavWidth : 0;
cameraDockBounds.minWidth = sidebarContentWidth;
@ -375,7 +387,11 @@ const CustomLayout = (props) => {
};
const calculatesMediaBounds = (sidebarNavWidth, sidebarContentWidth, cameraDockBounds) => {
const { isOpen } = presentationInput;
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const { height: actionBarHeight } = calculatesActionbarHeight();
const mediaAreaHeight = windowHeight()
- (DEFAULT_VALUES.navBarHeight + actionBarHeight + bannerAreaHeight());
@ -384,7 +400,10 @@ const CustomLayout = (props) => {
const { element: fullscreenElement } = fullscreen;
const { navBarHeight, camerasMargin } = DEFAULT_VALUES;
if (!isOpen) {
const hasPresentation = isPresentationEnabled() && slidesLength !== 0
const isGeneralMediaOff = !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
if (!isOpen || isGeneralMediaOff) {
mediaBounds.width = 0;
mediaBounds.height = 0;
mediaBounds.top = 0;

View File

@ -8,6 +8,7 @@ import CustomLayout from '/imports/ui/components/layout/layout-manager/customLay
import SmartLayout from '/imports/ui/components/layout/layout-manager/smartLayout';
import PresentationFocusLayout from '/imports/ui/components/layout/layout-manager/presentationFocusLayout';
import VideoFocusLayout from '/imports/ui/components/layout/layout-manager/videoFocusLayout';
import { isPresentationEnabled } from '/imports/ui/services/features';
const propTypes = {
layoutType: PropTypes.string.isRequired,
@ -23,6 +24,7 @@ const LayoutEngine = ({ layoutType }) => {
const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
const fullscreen = layoutSelect((i) => i.fullscreen);
const isRTL = layoutSelect((i) => i.isRTL);
@ -46,10 +48,10 @@ const LayoutEngine = ({ layoutType }) => {
};
const baseCameraDockBounds = (mediaAreaBounds, sidebarSize) => {
const { isOpen, currentSlide } = presentationInput;
const { num: currentSlideNumber } = currentSlide;
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const cameraDockBounds = {};
@ -60,9 +62,10 @@ const LayoutEngine = ({ layoutType }) => {
return cameraDockBounds;
}
const isSmartLayout = (layoutType === LAYOUT_TYPE.SMART_LAYOUT);
const hasPresentation = isPresentationEnabled() && slidesLength !== 0
const isGeneralMediaOff = !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
if (!isOpen || (isSmartLayout && currentSlideNumber === 0 && !hasExternalVideo && !hasScreenShare)) {
if (!isOpen || isGeneralMediaOff) {
cameraDockBounds.width = mediaAreaBounds.width;
cameraDockBounds.maxWidth = mediaAreaBounds.width;
cameraDockBounds.height = mediaAreaBounds.height - bannerAreaHeight();

View File

@ -8,6 +8,7 @@ import {
PANELS,
CAMERADOCK_POSITION,
} from '/imports/ui/components/layout/enums';
import { isPresentationEnabled } from '/imports/ui/services/features';
const windowWidth = () => window.document.documentElement.clientWidth;
const windowHeight = () => window.document.documentElement.clientHeight;
@ -33,6 +34,10 @@ const PresentationFocusLayout = (props) => {
const currentPanelType = layoutSelect((i) => i.currentPanelType);
const presentationInput = layoutSelectInput((i) => i.presentation);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
@ -147,7 +152,14 @@ const PresentationFocusLayout = (props) => {
};
const calculatesSidebarContentHeight = () => {
const { isOpen } = presentationInput;
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const hasPresentation = isPresentationEnabled() && slidesLength !== 0
const isGeneralMediaOff = !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
const {
navBarHeight,
sidebarContentMinHeight,
@ -160,7 +172,7 @@ const PresentationFocusLayout = (props) => {
height = windowHeight() - navBarHeight - bannerAreaHeight();
minHeight = height;
maxHeight = height;
} else if (cameraDockInput.numCameras > 0 && isOpen) {
} else if (cameraDockInput.numCameras > 0 && isOpen && !isGeneralMediaOff) {
if (sidebarContentInput.height === 0) {
height = (windowHeight() * 0.75) - bannerAreaHeight();
} else {
@ -221,13 +233,15 @@ const PresentationFocusLayout = (props) => {
max((windowHeight() - sidebarContentHeight), cameraDockMinHeight),
(windowHeight() - cameraDockMinHeight),
);
const bannerAreaDiff = windowHeight() - sidebarContentHeight - cameraDockHeight - bannerAreaHeight();
cameraDockHeight += bannerAreaDiff;
} else {
cameraDockHeight = min(
max(cameraDockInput.height, cameraDockMinHeight),
(windowHeight() - cameraDockMinHeight),
);
}
cameraDockBounds.top = windowHeight() - cameraDockHeight;
cameraDockBounds.top = windowHeight() - cameraDockHeight - bannerAreaHeight();
cameraDockBounds.left = !isRTL ? sidebarNavWidth : 0;
cameraDockBounds.right = isRTL ? sidebarNavWidth : 0;
cameraDockBounds.minWidth = sidebarContentWidth;

View File

@ -261,15 +261,18 @@ const SmartLayout = (props) => {
}
const calculatesMediaBounds = (mediaAreaBounds, slideSize, sidebarSize, screenShareSize) => {
const { isOpen, currentSlide } = presentationInput;
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const hasPresentation = isPresentationEnabled() && slidesLength !== 0
const isGeneralMediaOff = !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
const mediaBounds = {};
const { element: fullscreenElement } = fullscreen;
const { num: currentSlideNumber } = currentSlide;
if (!isOpen || ((isPresentationEnabled() && currentSlideNumber === 0) && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned)) {
if (!isOpen || isGeneralMediaOff) {
mediaBounds.width = 0;
mediaBounds.height = 0;
mediaBounds.top = 0;

View File

@ -9,6 +9,7 @@ import {
import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState';
import { ACTIONS, PANELS } from '/imports/ui/components/layout/enums';
import { isPresentationEnabled } from '/imports/ui/services/features';
const windowWidth = () => window.document.documentElement.clientWidth;
const windowHeight = () => window.document.documentElement.clientHeight;
@ -32,6 +33,10 @@ const VideoFocusLayout = (props) => {
const currentPanelType = layoutSelect((i) => i.currentPanelType);
const presentationInput = layoutSelectInput((i) => i.presentation);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
@ -154,6 +159,14 @@ const VideoFocusLayout = (props) => {
};
const calculatesSidebarContentHeight = () => {
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const hasPresentation = isPresentationEnabled() && slidesLength !== 0
const isGeneralMediaOff = !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
let minHeight = 0;
let height = 0;
let maxHeight = 0;
@ -162,7 +175,7 @@ const VideoFocusLayout = (props) => {
height = windowHeight() - DEFAULT_VALUES.navBarHeight - bannerAreaHeight();
minHeight = height;
maxHeight = height;
} else if (cameraDockInput.numCameras > 0 && presentationInput.isOpen) {
} else if (cameraDockInput.numCameras > 0 && isOpen && !isGeneralMediaOff) {
if (sidebarContentInput.height > 0 && sidebarContentInput.height < windowHeight()) {
height = sidebarContentInput.height - bannerAreaHeight();
} else {

View File

@ -4,7 +4,6 @@ import { defineMessages, injectIntl } from 'react-intl';
import { LAYOUT_TYPE } from '/imports/ui/components/layout/enums';
import { withModalMounter } from '/imports/ui/components/common/modal/service';
import SettingsService from '/imports/ui/components/settings/service';
import getFromUserSettings from '/imports/ui/services/users-settings';
import deviceInfo from '/imports/utils/deviceInfo';
import Toggle from '/imports/ui/components/common/switch/component';
import Button from '/imports/ui/components/common/button/component';
@ -26,10 +25,8 @@ const LayoutModalComponent = (props) => {
const [isKeepPushingLayout, setIsKeepPushingLayout] = useState(application.pushLayout);
const BASE_NAME = Meteor.settings.public.app.basename;
const CUSTOM_STYLE_URL = Boolean(Meteor.settings.public.app.customStyleUrl);
const customStyleUrl = Boolean(getFromUserSettings('bbb_custom_style_url', CUSTOM_STYLE_URL));
const LAYOUTS_PATH = `${BASE_NAME}/resources/images/layouts/${customStyleUrl ? 'customStyle/' : ''}`;
const LAYOUTS_PATH = `${BASE_NAME}/resources/images/layouts/`;
const isKeepPushingLayoutEnabled = SettingsService.isKeepPushingLayoutEnabled();
const intlMessages = defineMessages({

View File

@ -134,7 +134,7 @@ class Presentation extends PureComponent {
window.addEventListener('resize', this.onResize, false);
const {
currentSlide, slidePosition, layoutContextDispatch,
currentSlide, slidePosition, numPages, layoutContextDispatch,
} = this.props;
if (currentSlide) {
@ -149,6 +149,10 @@ class Presentation extends PureComponent {
height: slidePosition.height,
},
});
layoutContextDispatch({
type: ACTIONS.SET_PRESENTATION_SLIDES_LENGTH,
value: numPages,
})
}
}
@ -167,6 +171,7 @@ class Presentation extends PureComponent {
numCameras,
intl,
multiUser,
numPages,
} = this.props;
const {
@ -186,6 +191,13 @@ class Presentation extends PureComponent {
this.onResize();
}
if (numPages !== prevProps.numPages) {
layoutContextDispatch({
type: ACTIONS.SET_PRESENTATION_SLIDES_LENGTH,
value: numPages,
})
}
if (
currentSlide?.num != null
&& prevProps?.currentSlide?.num != null
@ -802,6 +814,7 @@ Presentation.propTypes = {
name: PropTypes.string.isRequired,
}),
presentationIsOpen: PropTypes.bool.isRequired,
numPages: PropTypes.number.isRequired,
publishedPoll: PropTypes.bool.isRequired,
presentationBounds: PropTypes.shape({
top: PropTypes.number,

View File

@ -71,6 +71,7 @@ const fetchedpresentation = {};
export default lockContextContainer(
withTracker(({ podId, presentationIsOpen, userLocks }) => {
const currentSlide = PresentationService.getCurrentSlide(podId);
const numPages = PresentationService.getSlidesLength(podId);
const presentationIsDownloadable = PresentationService.isPresentationDownloadable(podId);
const isViewersCursorLocked = userLocks?.hideViewersCursor;
@ -127,6 +128,7 @@ export default lockContextContainer(
presentationIsDownloadable,
mountPresentation: !!currentSlide,
currentPresentation: PresentationService.getCurrentPresentation(podId),
numPages,
notify,
zoomSlide: PresentationToolbarService.zoomSlide,
podId,

View File

@ -57,6 +57,14 @@ const intlMessages = defineMessages({
description: 'used for aria whiteboard options button label',
defaultMessage: 'Whiteboard',
},
hideToolsDesc: {
id: 'app.presentation.presentationToolbar.hideToolsDesc',
description: 'Hide toolbar label',
},
showToolsDesc: {
id: 'app.presentation.presentationToolbar.showToolsDesc',
description: 'Show toolbar label',
},
});
const propTypes = {
@ -127,6 +135,11 @@ const PresentationMenu = (props) => {
? intl.formatMessage(intlMessages.exitFullscreenLabel)
: intl.formatMessage(intlMessages.fullscreenLabel)
);
const formattedVisibilityLabel = (visible) => (visible
? intl.formatMessage(intlMessages.hideToolsDesc)
: intl.formatMessage(intlMessages.showToolsDesc)
);
function renderToastContent() {
const { loading, hasError } = state;
@ -255,6 +268,36 @@ const PresentationMenu = (props) => {
},
);
}
const tools = document.querySelector('#TD-Tools');
if (tools && (props.hasWBAccess || props.amIPresenter)){
const isVisible = tools.style.visibility == 'hidden' ? false : true;
const styles = document.querySelector('#TD-Styles').parentElement;
const option = document.querySelector('#WhiteboardOptionButton');
if (option) {
//When the RTL-LTR changed, the toolbar appears again,
// while the opacity of this button remains the same.
//So we need to reset the opacity here.
option.style.opacity = isVisible ? 'unset' : '0.2';
}
menuItems.push(
{
key: 'list-item-toolvisibility',
dataTest: 'toolVisibility',
label: formattedVisibilityLabel(isVisible),
icon: isVisible ? 'close' : 'pen_tool',
onClick: () => {
tools.style.visibility = isVisible ? 'hidden' : 'visible';
if (styles) {
styles.style.visibility = isVisible ? 'hidden' : 'visible';
}
if (option) {
option.style.opacity = isVisible ? '0.2' : 'unset';
}
},
},
);
}
return menuItems;
}
@ -294,7 +337,7 @@ const PresentationMenu = (props) => {
}
return (
<Styled.Right>
<Styled.Right id='WhiteboardOptionButton'>
<BBBMenu
trigger={(
<TooltipContainer title={intl.formatMessage(intlMessages.optionsLabel)}>

View File

@ -6,6 +6,8 @@ import FullscreenService from '/imports/ui/components/common/fullscreen-button/s
import Auth from '/imports/ui/services/auth';
import Meetings from '/imports/api/meetings';
import { layoutSelect, layoutDispatch } from '/imports/ui/components/layout/context';
import WhiteboardService from '/imports/ui/components/whiteboard/service';
import UserService from '/imports/ui/components/user-list/service';
const PresentationMenuContainer = (props) => {
const fullscreen = layoutSelect((i) => i.fullscreen);
@ -34,6 +36,8 @@ export default withTracker((props) => {
const isIphone = !!(navigator.userAgent.match(/iPhone/i));
const meetingId = Auth.meetingID;
const meetingObject = Meetings.findOne({ meetingId }, { fields: { 'meetingProp.name': 1 } });
const hasWBAccess = WhiteboardService.hasMultiUserAccess(WhiteboardService.getCurrentWhiteboardId(), Auth.userID);
const amIPresenter = UserService.isUserPresenter(Auth.userID);
return {
...props,
@ -41,6 +45,8 @@ export default withTracker((props) => {
isIphone,
isDropdownOpen: Session.get('dropdownOpen'),
meetingName: meetingObject.meetingProp.name,
hasWBAccess,
amIPresenter,
};
})(PresentationMenuContainer);

View File

@ -56,6 +56,10 @@ const getCurrentSlide = (podId) => {
});
};
const getSlidesLength = (podId) => {
return getCurrentPresentation(podId)?.pages?.length || 0;
}
const getSlidePosition = (podId, presentationId, slideId) => SlidePositions.findOne({
podId,
presentationId,
@ -222,4 +226,5 @@ export default {
currentSlidHasContent,
parseCurrentSlideContent,
getCurrentPresentation,
getSlidesLength,
};

View File

@ -242,9 +242,16 @@ class ScreenshareComponent extends React.Component {
try {
mediaFlowing = isMediaFlowing(previousStats, currentStats);
} catch (_error) {
} catch (error) {
// Stats processing failed for whatever reason - maintain previous state
mediaFlowing = prevMediaFlowing;
logger.warn({
logCode: 'screenshare_media_monitor_stats_failed',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, 'Failed to collect screenshare stats, flow monitor');
}
previousStats = currentStats;
@ -323,9 +330,7 @@ class ScreenshareComponent extends React.Component {
this.clearMediaFlowingMonitor();
// Current state is media not flowing - stream is now healthy so flip it
if (!mediaFlowing) this.setState({ mediaFlowing: isStreamHealthy });
} else {
if (this.mediaFlowMonitor == null) this.monitorMediaFlow();
}
} else if (this.mediaFlowMonitor == null) this.monitorMediaFlow();
}
renderFullscreenButton() {

View File

@ -245,8 +245,8 @@ const getStats = async (statsTypes = DEFAULT_SCREENSHARE_STATS_TYPES) => {
// This method may throw errors
const isMediaFlowing = (previousStats, currentStats) => {
const bpsData = ConnectionStatusService.calculateBitsPerSecond(
currentStats.screenshareStats,
previousStats.screenshareStats,
currentStats?.screenshareStats,
previousStats?.screenshareStats,
);
const bpsDataAggr = Object.values(bpsData)
.reduce((sum, partialBpsData = 0) => sum + parseFloat(partialBpsData), 0);

View File

@ -322,8 +322,30 @@ const getActiveChats = ({ groupChatsMessages, groupChats, users }) => {
});
const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
return chatInfo.filter((chat) => !currentClosedChats.includes(chat.chatId)
const removeClosedChats = chatInfo.filter((chat) => !currentClosedChats.includes(chat.chatId)
&& chat.shouldDisplayInChatList);
const sortByChatIdAndUnread = removeClosedChats.sort((a, b) => {
if (a.chatId === PUBLIC_GROUP_CHAT_ID) {
return -1;
}
if (b.chatId === PUBLIC_CHAT_ID) {
return 0;
}
if (a.unreadCounter > b.unreadCounter) {
return -1;
} else if (b.unreadCounter > a.unreadCounter) {
return 1;
} else {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
return 0;
}
});
return sortByChatIdAndUnread;
};
const isVoiceOnlyUser = (userId) => userId.toString().startsWith('v_');

View File

@ -305,6 +305,7 @@ const VirtualBgSelector = ({
disabled={disabled}
isVisualEffects={isVisualEffects}
background={getVirtualBackgroundThumbnail(imageName)}
data-test="selectDefaultBackground"
/>
<div aria-hidden className="sr-only" id={`vr-cam-btn-${index + 1}`}>
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: label })}
@ -343,6 +344,7 @@ const VirtualBgSelector = ({
disabled={disabled}
isVisualEffects={isVisualEffects}
background={data}
data-test="selectCustomBackground"
/>
<Styled.ButtonWrapper>
<Styled.ButtonRemove
@ -387,6 +389,7 @@ const VirtualBgSelector = ({
}
}}
isVisualEffects={isVisualEffects}
data-test="inputBackgroundButton"
/>
<input
ref={customBgSelectorRef}
@ -414,6 +417,7 @@ const VirtualBgSelector = ({
disabled={disabled}
onClick={() => _virtualBgSelected(EFFECT_TYPES.NONE_TYPE)}
isVisualEffects={isVisualEffects}
data-test="noneBackgroundButton"
/>
<div aria-hidden className="sr-only" id={`vr-cam-btn-none`}>
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: EFFECT_TYPES.NONE_TYPE })}

View File

@ -25,7 +25,16 @@ import WebRtcPeer from '/imports/ui/services/webrtc-base/peer';
// Default values and default empty object to be backwards compat with 2.2.
// FIXME Remove hardcoded defaults 2.3.
const WS_CONN_TIMEOUT = Meteor.settings.public.kurento.wsConnectionTimeout || 4000;
const {
connectionTimeout: WS_CONN_TIMEOUT = 4000,
maxRetries: WS_MAX_RETRIES = 5,
debug: WS_DEBUG,
heartbeat: WS_HEARTBEAT_OPTS = {
interval: 15000,
delay: 3000,
reconnectOnFailure: true,
},
} = Meteor.settings.public.kurento.cameraWsOptions;
const { webcam: NETWORK_PRIORITY } = Meteor.settings.public.media.networkPriorities || {};
const {
@ -36,9 +45,9 @@ const {
enabled: CAMERA_QUALITY_THRESHOLDS_ENABLED = true,
privilegedStreams: CAMERA_QUALITY_THR_PRIVILEGED = true,
} = Meteor.settings.public.kurento.cameraQualityThresholds;
const PING_INTERVAL = 15000;
const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
const intlClientErrors = defineMessages({
permissionError: {
@ -113,6 +122,7 @@ const propTypes = {
swapLayout: PropTypes.bool.isRequired,
currentVideoPageIndex: PropTypes.number.isRequired,
totalNumberOfStreams: PropTypes.number.isRequired,
isMeteorConnected: PropTypes.bool.isRequired,
};
class VideoProvider extends Component {
@ -120,6 +130,23 @@ class VideoProvider extends Component {
VideoService.onBeforeUnload();
}
static isAbleToAttach(peer) {
// Conditions to safely attach a stream to a video element in all browsers:
// 1 - Peer exists
// 2 - It hasn't been attached yet
// 3a - If the stream is a remote one, the safest (*ahem* Safari) moment to
// do so is waiting for the server to confirm that media has flown out of it
// towards te remote end (peer.started)
// 3b - If the stream is a local one (webcam sharer) and is started
// 4 - If the stream is local one, check if there area video tracks there are
// video tracks: attach it
if (peer == null || peer.attached) return false;
if (peer.started) return true;
return peer.isPublisher
&& peer.getLocalStream()
&& peer.getLocalStream().getVideoTracks().length > 0;
}
constructor(props) {
super(props);
@ -128,16 +155,9 @@ class VideoProvider extends Component {
socketOpen: false,
};
this._isMounted = false;
this.info = VideoService.getInfo();
// Set a valid bbb-webrtc-sfu application server socket in the settings
this.ws = new ReconnectingWebSocket(
VideoService.getAuthenticatedURL(),
[],
{ connectionTimeout: WS_CONN_TIMEOUT },
);
this.wsQueue = [];
// Signaling message queue arrays indexed by stream (== cameraId)
this.wsQueues = {};
this.restartTimeout = {};
this.restartTimer = {};
this.webRtcPeers = {};
@ -161,53 +181,120 @@ class VideoProvider extends Component {
componentDidMount() {
this._isMounted = true;
VideoService.updatePeerDictionaryReference(this.webRtcPeers);
this.ws.onopen = this.onWsOpen;
this.ws.onclose = this.onWsClose;
window.addEventListener('online', this.openWs);
window.addEventListener('offline', this.onWsClose);
this.ws.onmessage = this.onWsMessage;
this.ws = this.openWs();
window.addEventListener('beforeunload', VideoProvider.onBeforeUnload);
}
componentDidUpdate(prevProps) {
const { isUserLocked, streams, currentVideoPageIndex } = this.props;
const {
isUserLocked,
streams,
currentVideoPageIndex,
isMeteorConnected
} = this.props;
const { socketOpen } = this.state;
// Only debounce when page changes to avoid unecessary debouncing
const shouldDebounce = VideoService.isPaginationEnabled()
&& prevProps.currentVideoPageIndex !== currentVideoPageIndex;
this.updateStreams(streams, shouldDebounce);
if (isMeteorConnected && socketOpen) this.updateStreams(streams, shouldDebounce);
if (!prevProps.isUserLocked && isUserLocked) VideoService.lockUser();
// Signaling socket expired its retries and meteor is connected - create
// a new signaling socket instance from scratch
if (!socketOpen
&& isMeteorConnected
&& this.ws == null) {
this.ws = this.openWs();
}
}
componentWillUnmount() {
this._isMounted = false;
VideoService.updatePeerDictionaryReference({});
this.ws.onmessage = null;
this.ws.onopen = null;
this.ws.onclose = null;
window.removeEventListener('online', this.openWs);
window.removeEventListener('offline', this.onWsClose);
window.removeEventListener('beforeunload', VideoProvider.onBeforeUnload);
VideoService.exitVideo();
Object.keys(this.webRtcPeers).forEach((stream) => {
this.stopWebRTCPeer(stream, false);
});
this.terminateWs();
}
// Close websocket connection to prevent multiple reconnects from happening
this.ws.close();
this._isMounted = false;
openWs() {
const ws = new ReconnectingWebSocket(
VideoService.getAuthenticatedURL(), [], {
connectionTimeout: WS_CONN_TIMEOUT,
debug: WS_DEBUG,
maxRetries: WS_MAX_RETRIES,
maxEnqueuedMessages: 0,
}
);
ws.onopen = this.onWsOpen;
ws.onclose = this.onWsClose;
ws.onmessage = this.onWsMessage;
return ws;
}
terminateWs() {
if (this.ws) {
this.clearWSHeartbeat();
this.ws.close();
this.ws = null;
}
}
_updateLastMsgTime() {
this.ws.isAlive = true;
this.ws.lastMsgTime = Date.now();
}
_getTimeSinceLastMsg() {
return Date.now() - this.ws.lastMsgTime;
}
setupWSHeartbeat() {
if (WS_HEARTBEAT_OPTS.interval === 0 || this.ws == null || this.ws.wsHeartbeat) return;
this.ws.isAlive = true;
this.ws.wsHeartbeat = setInterval(() => {
if (this.ws.isAlive === false) {
logger.warn({
logCode: 'video_provider_ws_heartbeat_failed',
}, 'Video provider WS heartbeat failed.');
if (WS_HEARTBEAT_OPTS.reconnectOnFailure) this.ws.reconnect();
return;
}
if (this._getTimeSinceLastMsg() < (
WS_HEARTBEAT_OPTS.interval - WS_HEARTBEAT_OPTS.delay
)) {
return;
}
this.ws.isAlive = false;
this.ping();
}, WS_HEARTBEAT_OPTS.interval);
this.ping();
}
clearWSHeartbeat() {
if (this.ws?.wsHeartbeat) {
clearInterval(this.ws.wsHeartbeat);
this.ws.wsHeartbeat = null;
}
}
onWsMessage(message) {
this._updateLastMsgTime();
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.id === 'pong') return;
@ -244,11 +331,22 @@ class VideoProvider extends Component {
logCode: 'video_provider_onwsclose',
}, 'Multiple video provider websocket connection closed.');
clearInterval(this.pingInterval);
this.clearWSHeartbeat();
VideoService.exitVideo();
// Media is currently tied to signaling state - so if signaling shuts down,
// media will shut down server-side. This cleans up our local state faster
// and notify the state change as failed so the UI rolls back to the placeholder
// avatar UI in the camera container
Object.keys(this.webRtcPeers).forEach((stream) => {
if (this.stopWebRTCPeer(stream, false)) {
notifyStreamStateChange(stream, 'failed');
}
});
this.setState({ socketOpen: false });
if (this.ws && this.ws.retryCount >= WS_MAX_RETRIES) {
this.terminateWs();
}
}
onWsOpen() {
@ -256,14 +354,21 @@ class VideoProvider extends Component {
logCode: 'video_provider_onwsopen',
}, 'Multiple video provider websocket connection opened.');
// Resend queued messages that happened when socket was not connected
while (this.wsQueue.length > 0) {
this.sendMessage(this.wsQueue.pop());
}
this.pingInterval = setInterval(this.ping.bind(this), PING_INTERVAL);
this._updateLastMsgTime();
this.setupWSHeartbeat();
this.setState({ socketOpen: true });
// Resend queued messages that happened when socket was not connected
Object.entries(this.wsQueues).forEach(([stream, queue]) => {
if (this.webRtcPeers[stream]) {
// Peer - send enqueued
while (queue.length > 0) {
this.sendMessage(queue.pop());
}
} else {
// No peer - delete queue
this.wsQueues[stream] = null;
}
});
}
findAllPrivilegedStreams () {
@ -342,25 +447,29 @@ class VideoProvider extends Component {
if (this.connectedToMediaServer()) {
const jsonMessage = JSON.stringify(message);
ws.send(jsonMessage, (error) => {
if (error) {
logger.error({
logCode: 'video_provider_ws_send_error',
extraInfo: {
errorMessage: error.message || 'Unknown',
errorCode: error.code,
},
}, 'Camera request failed to be sent to SFU');
}
});
try {
ws.send(jsonMessage);
} catch (error) {
logger.error({
logCode: 'video_provider_ws_send_error',
extraInfo: {
errorMessage: error.message || 'Unknown',
errorCode: error.code,
},
}, 'Camera request failed to be sent to SFU');
}
} else if (message.id !== 'stop') {
// No need to queue video stop messages
this.wsQueue.push(message);
const { cameraId } = message;
if (cameraId) {
if (this.wsQueues[cameraId] == null) this.wsQueues[cameraId] = [];
this.wsQueues[cameraId].push(message);
}
}
}
connectedToMediaServer() {
return this.ws.readyState === WebSocket.OPEN;
return this.ws && this.ws.readyState === ReconnectingWebSocket.OPEN;
}
processOutboundIceQueue(peer, role, stream) {
@ -494,17 +603,20 @@ class VideoProvider extends Component {
this.clearRestartTimers(stream);
}
this.destroyWebRTCPeer(stream);
return this.destroyWebRTCPeer(stream);
}
destroyWebRTCPeer(stream) {
let stopped = false;
const peer = this.webRtcPeers[stream];
const isLocal = VideoService.isLocalStream(stream);
const role = VideoService.getRole(isLocal);
if (peer) {
if (peer && peer.bbbVideoStream) {
peer.bbbVideoStream.removeListener('inactive', peer.inactivationHandler);
if (typeof peer.inactivationHandler === 'function') {
peer.bbbVideoStream.removeListener('inactive', peer.inactivationHandler);
}
peer.bbbVideoStream.stop();
}
@ -512,14 +624,19 @@ class VideoProvider extends Component {
peer.dispose();
}
delete this.outboundIceQueues[stream];
delete this.webRtcPeers[stream];
stopped = true;
} else {
logger.warn({
logCode: 'video_provider_destroywebrtcpeer_no_peer',
extraInfo: { cameraId: stream, role },
}, 'Trailing camera destroy request.');
}
delete this.outboundIceQueues[stream];
delete this.wsQueues[stream];
return stopped;
}
_createPublisher(stream, peerOptions) {
@ -618,6 +735,7 @@ class VideoProvider extends Component {
},
trace: TRACE_LOGS,
networkPriorities: NETWORK_PRIORITY ? { video: NETWORK_PRIORITY } : undefined,
gatheringTimeout: GATHERING_TIMEOUT,
};
try {
@ -661,10 +779,6 @@ class VideoProvider extends Component {
cameraId: stream,
role,
sdpOffer: offer,
meetingId: this.info.meetingId,
voiceBridge: this.info.voiceBridge,
userId: this.info.userId,
userName: this.info.userName,
bitrate,
record: VideoService.getRecord(),
mediaServer: VideoService.getMediaServerAdapter(),
@ -678,8 +792,8 @@ class VideoProvider extends Component {
},
}, `Camera offer generated. Role: ${role}`);
this.sendMessage(message);
this.setReconnectionTimeout(stream, isLocal, false);
this.sendMessage(message);
return;
}).catch(error => {
@ -738,7 +852,7 @@ class VideoProvider extends Component {
}
_onWebRTCError(error, stream, isLocal) {
const { intl } = this.props;
const { intl, streams } = this.props;
const { name: errorName, message: errorMessage } = error;
const errorLocale = intlClientErrors[errorName]
|| intlClientErrors[errorMessage]
@ -763,11 +877,16 @@ class VideoProvider extends Component {
// If it's a viewer, set the reconnection timeout. There's a good chance
// no local candidate was generated and it wasn't set.
const peer = this.webRtcPeers[stream];
const isEstablishedConnection = peer && peer.started;
this.setReconnectionTimeout(stream, isLocal, isEstablishedConnection);
const stillExists = streams.some(({ stream: streamId }) => streamId === stream);
if (stillExists) {
const isEstablishedConnection = peer && peer.started;
this.setReconnectionTimeout(stream, isLocal, isEstablishedConnection);
}
// second argument means it will only try to reconnect if
// it's a viewer instance (see stopWebRTCPeer restarting argument)
this.stopWebRTCPeer(stream, true);
this.stopWebRTCPeer(stream, stillExists);
}
}
@ -917,16 +1036,7 @@ class VideoProvider extends Component {
return; // Skip if the stream is already attached
}
// Conditions to safely attach a stream to a video element in all browsers:
// 1 - Peer exists
// 2 - It hasn't been attached yet
// 3a - If the stream is a local one (webcam sharer), we can just attach it
// (no need to wait for server confirmation)
// 3b - If the stream is a remote one, the safest (*ahem* Safari) moment to
// do so is waiting for the server to confirm that media has flown out of it
// towards the remote end.
const isAbleToAttach = peer && !peer.attached && (peer.started || isLocal);
if (isAbleToAttach) {
if (VideoProvider.isAbleToAttach(peer)) {
this.attach(peer, video);
peer.attached = true;
@ -1075,7 +1185,7 @@ class VideoProvider extends Component {
}
handleSFUError(message) {
const { intl } = this.props;
const { intl, streams } = this.props;
const { code, reason, streamId } = message;
const isLocal = VideoService.isLocalStream(streamId);
const role = VideoService.getRole(isLocal);
@ -1093,15 +1203,22 @@ class VideoProvider extends Component {
if (isLocal) {
// The publisher instance received an error from the server. There's no reconnect,
// stop it.
VideoService.stopVideo(streamId);
VideoService.notify(intl.formatMessage(intlSFUErrors[code] || intlSFUErrors[2200]));
VideoService.stopVideo(streamId);
} else {
this.stopWebRTCPeer(streamId, true);
const peer = this.webRtcPeers[streamId];
const stillExists = streams.some(({ stream }) => streamId === stream);
if (stillExists) {
const isEstablishedConnection = peer && peer.started;
this.setReconnectionTimeout(streamId, isLocal, isEstablishedConnection);
}
this.stopWebRTCPeer(streamId, stillExists);
}
}
replacePCVideoTracks (streamId, mediaStream) {
let replaced = false;
replacePCVideoTracks(streamId, mediaStream) {
const peer = this.webRtcPeers[streamId];
const videoElement = this.getVideoElement(streamId);
@ -1111,26 +1228,24 @@ class VideoProvider extends Component {
const newTracks = mediaStream.getVideoTracks();
if (pc) {
try {
pc.getSenders().forEach((sender, index) => {
if (sender.track && sender.track.kind === 'video') {
const newTrack = newTracks[index];
if (newTrack == null) return;
sender.replaceTrack(newTrack);
replaced = true;
}
});
} catch (error) {
logger.error({
logCode: 'video_provider_replacepc_error',
extraInfo: { errorMessage: error.message, cameraId: streamId },
}, `Failed to replace peer connection tracks: ${error.message}`);
}
}
if (replaced) {
peer.localStream = mediaStream;
this.attach(peer, videoElement);
const trackReplacers = pc.getSenders().map(async (sender, index) => {
if (sender.track == null || sender.track.kind !== 'video') return false;
const newTrack = newTracks[index];
if (newTrack == null) return false;
try {
await sender.replaceTrack(newTrack);
return true;
} catch (error) {
logger.warn({
logCode: 'video_provider_replacepc_error',
extraInfo: { errorMessage: error.message, cameraId: streamId },
}, `Failed to replace peer connection tracks: ${error.message}`);
return false;
}
});
Promise.all(trackReplacers).then(() => {
this.attach(peer, videoElement);
});
}
}

View File

@ -25,6 +25,7 @@ export default withTracker(({ swapLayout, ...rest }) => {
totalNumberOfStreams,
isUserLocked: VideoService.isUserLocked(),
currentVideoPageIndex: VideoService.getCurrentVideoPageIndex(),
isMeteorConnected: Meteor.status().connected,
...rest,
};
})(VideoProviderContainer);

View File

@ -131,6 +131,7 @@ const JoinVideoButton = ({
key: 'advancedVideo',
label: intl.formatMessage(intlMessages.advancedVideo),
onClick: () => handleOpenAdvancedOptions(),
dataTest: 'advancedVideoSettingsButton',
},
);
}
@ -154,6 +155,7 @@ const JoinVideoButton = ({
trigger={(
<ButtonEmoji
emoji="device_list_selector"
data-test="videoDropdownMenu"
hideLabel
label={intl.formatMessage(intlMessages.videoSettings)}
rotate

View File

@ -26,7 +26,7 @@ const VideoListItem = (props) => {
makeDragOperations, dragging, draggingOver, isRTL
} = props;
const [videoIsReady, setVideoIsReady] = useState(false);
const [videoDataLoaded, setVideoDataLoaded] = useState(false);
const [isStreamHealthy, setIsStreamHealthy] = useState(false);
const [isMirrored, setIsMirrored] = useState(VideoService.mirrorOwnWebcam(user?.userId));
const [isVideoSqueezed, setIsVideoSqueezed] = useState(false);
@ -41,7 +41,7 @@ const VideoListItem = (props) => {
const videoTag = useRef();
const videoContainer = useRef();
const shouldRenderReconnect = !isStreamHealthy && videoIsReady;
const videoIsReady = isStreamHealthy && videoDataLoaded;
const { animations } = Settings.application;
const talking = voiceUser?.talking;
@ -49,14 +49,11 @@ const VideoListItem = (props) => {
const { streamState } = e.detail;
const newHealthState = !isStreamStateUnhealthy(streamState);
e.stopPropagation();
if (newHealthState !== isStreamHealthy) {
setIsStreamHealthy(newHealthState);
}
setIsStreamHealthy(newHealthState);
};
const handleSetVideoIsReady = () => {
setVideoIsReady(true);
const onLoadedData = () => {
setVideoDataLoaded(true);
window.dispatchEvent(new Event('resize'));
/* used when re-sharing cameras after leaving a breakout room.
@ -71,10 +68,10 @@ const VideoListItem = (props) => {
onVideoItemMount(videoTag.current);
subscribeToStreamStateChange(cameraId, onStreamStateChange);
resizeObserver.observe(videoContainer.current);
videoTag?.current?.addEventListener('loadeddata', handleSetVideoIsReady);
videoTag?.current?.addEventListener('loadeddata', onLoadedData);
return () => {
videoTag?.current?.removeEventListener('loadeddata', handleSetVideoIsReady);
videoTag?.current?.removeEventListener('loadeddata', onLoadedData);
resizeObserver.disconnect();
};
}, []);
@ -96,10 +93,10 @@ const VideoListItem = (props) => {
// This is here to prevent the videos from freezing when they're
// moved around the dom by react, e.g., when changing the user status
// see https://bugs.chromium.org/p/chromium/issues/detail?id=382879
if (videoIsReady) {
if (videoDataLoaded) {
playElement(videoTag.current);
}
}, [videoIsReady]);
}, [videoDataLoaded]);
// component will unmount
useEffect(() => () => {
@ -130,7 +127,7 @@ const VideoListItem = (props) => {
<UserAvatarVideo
user={user}
voiceUser={voiceUser}
unhealthyStream={shouldRenderReconnect}
unhealthyStream={videoDataLoaded && !isStreamHealthy}
squeezed={false}
/>
<Styled.BottomBar>
@ -158,7 +155,7 @@ const VideoListItem = (props) => {
>
<UserAvatarVideo
user={user}
unhealthyStream={shouldRenderReconnect}
unhealthyStream={videoDataLoaded && !isStreamHealthy}
squeezed
/>
{renderSqueezedButton()}
@ -213,7 +210,7 @@ const VideoListItem = (props) => {
<Styled.VideoContainer>
<Styled.Video
mirrored={isMirrored}
unhealthyStream={shouldRenderReconnect}
unhealthyStream={videoDataLoaded && !isStreamHealthy}
data-test={isMirrored ? 'mirroredVideoContainer' : 'videoContainer'}
ref={videoTag}
autoPlay
@ -229,8 +226,6 @@ const VideoListItem = (props) => {
: (isVideoSqueezed)
? renderWebcamConnectingSqueezed()
: renderWebcamConnecting()}
{shouldRenderReconnect && <Styled.Reconnecting animations={animations} />}
</Styled.Content>
);
};

View File

@ -109,29 +109,6 @@ const LoadingText = styled(TextElipsis)`
font-size: 100%;
`;
const Reconnecting = styled.div`
position: absolute;
height: 100%;
width: 100%;
display: flex;
font-size: 2.5rem;
z-index: 1;
align-items: center;
justify-content: center;
background-color: transparent;
color: ${colorWhite};
&::before {
font-family: 'bbb-icons' !important;
content: "\\e949";
/* ascii code for the ellipsis character */
display: inline-block;
${({ animations }) => animations && css`
animation: ${rotate360} 2s infinite linear;
`}
}
`;
const VideoContainer = styled.div`
display: flex;
justify-content: center;
@ -180,7 +157,6 @@ export default {
Content,
WebcamConnecting,
LoadingText,
Reconnecting,
VideoContainer,
Video,
TopBar,

View File

@ -90,6 +90,7 @@ const UserActions = (props) => {
label: intl.formatMessage(intlMessages.mirrorLabel),
description: intl.formatMessage(intlMessages.mirrorDesc),
onClick: () => onHandleMirror(),
dataTest: 'mirrorWebcamBtn',
});
if (numOfStreams > 2) {
@ -98,6 +99,7 @@ const UserActions = (props) => {
label: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Label`]),
description: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Desc`]),
onClick: () => onHandleVideoFocus(cameraId),
dataTest: 'FocusWebcamBtn',
});
}
@ -107,6 +109,7 @@ const UserActions = (props) => {
label: intl.formatMessage(intlMessages[`${isPinnedIntlKey}Label`]),
description: intl.formatMessage(intlMessages[`${isPinnedIntlKey}Desc`]),
onClick: () => VideoService.toggleVideoPin(userId, pinned),
dataTest: 'pinWebcamBtn',
});
}

View File

@ -91,6 +91,7 @@ export default function Whiteboard(props) {
const prevSvgUri = usePrevious(svgUri);
const language = mapLanguage(Settings?.application?.locale?.toLowerCase() || 'en');
const [currentTool, setCurrentTool] = React.useState(null);
const [currentStyle, setCurrentStyle] = React.useState({});
const [isMoving, setIsMoving] = React.useState(false);
const [isPanning, setIsPanning] = React.useState(shortcutPanning);
const [panSelected, setPanSelected] = React.useState(isPanning);
@ -110,12 +111,6 @@ export default function Whiteboard(props) {
}
};
const setSafeCurrentTool = (tool) => {
if (isMountedRef.current) {
setCurrentTool(tool);
}
};
const toggleOffCheck = (evt) => {
const clickedElement = evt.target;
const panBtnClicked = clickedElement?.getAttribute('data-test') === 'panButton'
@ -594,8 +589,6 @@ export default function Whiteboard(props) {
const onMount = (app) => {
const menu = document.getElementById('TD-Styles')?.parentElement;
setSafeCurrentTool('select');
const canvas = document.getElementById('canvas');
if (canvas) {
canvas.addEventListener('wheel', handleWheelEvent, { capture: true });
@ -825,6 +818,16 @@ export default function Whiteboard(props) {
setIsPanning(false);
}
if (reason && reason.includes('ui:toggled_is_loading')) {
e?.patchState(
{
appState: {
currentStyle,
},
},
);
}
e?.patchState(
{
appState: {
@ -832,6 +835,10 @@ export default function Whiteboard(props) {
},
},
);
if ((panSelected || isPanning)) {
e.isForcePanning = isPanning;
}
};
const onUndo = (app) => {
@ -880,6 +887,11 @@ export default function Whiteboard(props) {
if (!isFirstCommand){
setHistory(app.history);
}
if (command?.id?.includes('style')) {
setCurrentStyle({ ...currentStyle, ...command?.after?.appState?.currentStyle });
}
const changedShapes = command.after?.document?.pages[app.currentPageId]?.shapes;
if (!isMounting && app.currentPageId !== curPageId) {
// can happen then the "move to page action" is called, or using undo after changing a page
@ -968,10 +980,6 @@ export default function Whiteboard(props) {
const size = ((height < SMALL_HEIGHT) || (width < SMALL_WIDTH))
? TOOLBAR_SMALL : TOOLBAR_LARGE;
if ((panSelected || isPanning) && tldrawAPI) {
tldrawAPI.isForcePanning = isPanning;
}
if (hasWBAccess || isPresenter) {
if (((height < SMALLEST_HEIGHT) || (width < SMALLEST_WIDTH))) {
tldrawAPI?.setSetting('dockPosition', 'bottom');
@ -1015,6 +1023,7 @@ export default function Whiteboard(props) {
size,
darkTheme,
menuOffset,
panSelected,
}}
/>
</Cursors>

View File

@ -101,6 +101,9 @@ class PanToolInjector extends React.Component {
}
}}
hideLabel
{...{
panSelected,
}}
/>,
container,
);

View File

@ -72,6 +72,14 @@ const TldrawGlobalStyle = createGlobalStyle`
margin: ${borderSize} ${borderSizeLarge} 0px ${borderSizeLarge};
}
`}
${({ hasWBAccess, isPresenter, panSelected }) => (hasWBAccess || isPresenter) && panSelected && `
[id^="TD-PrimaryTools-"] {
&:hover > div,
&:focus > div {
background-color: var(--colors-hover) !important;
}
}
`}
${({ darkTheme }) => darkTheme && `
#TD-TopPanel-Undo,
#TD-TopPanel-Redo,
@ -113,10 +121,12 @@ const PanTool = styled(Button)`
}
}
&:hover,
&:focus {
background-color: var(--colors-hover);
}
${({ panSelected }) => !panSelected && `
&:hover,
&:focus {
background-color: var(--colors-hover) !important;
}
`}
`;
export default {

View File

@ -29,6 +29,7 @@ class AudioBroker extends BaseBroker {
// signalCandidates
// traceLogs
// networkPriority
// gatheringTimeout
Object.assign(this, options);
}
@ -85,6 +86,7 @@ class AudioBroker extends BaseBroker {
trace: this.traceLogs,
networkPriorities: this.networkPriority ? { audio: this.networkPriority } : undefined,
mediaStreamFactory: this.mediaStreamFactory,
gatheringTimeout: this.gatheringTimeout,
};
const peerRole = this.role === 'sendrecv' ? this.role : 'recvonly';

View File

@ -38,6 +38,7 @@ class ScreenshareBroker extends BaseBroker {
// signalCandidates,
// traceLogs
// networkPriority
// gatheringTimeout
Object.assign(this, options);
}
@ -189,6 +190,7 @@ class ScreenshareBroker extends BaseBroker {
configuration: this.populatePeerConfiguration(),
trace: this.traceLogs,
networkPriorities: this.networkPriority ? { video: this.networkPriority } : undefined,
gatheringTimeout: this.gatheringTimeout,
};
this.webRtcPeer = new WebRtcPeer('sendonly', options);
this.webRtcPeer.iceQueue = [];
@ -248,6 +250,7 @@ class ScreenshareBroker extends BaseBroker {
onicecandidate: this.signalCandidates ? this.onIceCandidate.bind(this) : null,
configuration: this.populatePeerConfiguration(),
trace: this.traceLogs,
gatheringTimeout: this.gatheringTimeout,
};
this.webRtcPeer = new WebRtcPeer('recvonly', options);

View File

@ -2,7 +2,10 @@ import logger from '/imports/startup/client/logger';
import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
import { SFU_BROKER_ERRORS } from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors';
const PING_INTERVAL_MS = 15000;
const WS_HEARTBEAT_OPTS = {
interval: 15000,
delay: 3000,
};
class BaseBroker {
static assembleError(code, reason) {
@ -21,13 +24,14 @@ class BaseBroker {
this.sfuComponent = sfuComponent;
this.ws = null;
this.webRtcPeer = null;
this.pingInterval = null;
this.wsHeartbeat = null;
this.started = false;
this.signallingTransportOpen = false;
this.logCodePrefix = `${this.sfuComponent}_broker`;
this.peerConfiguration = {};
this.onbeforeunload = this.onbeforeunload.bind(this);
this._onWSError = this._onWSError.bind(this);
window.addEventListener('beforeunload', this.onbeforeunload);
}
@ -63,48 +67,125 @@ class BaseBroker {
// To be implemented by inheritors
}
_onWSMessage(message) {
this._updateLastMsgTime();
this.onWSMessage(message);
}
onWSMessage(message) {
// To be implemented by inheritors
}
_onWSError(error) {
let normalizedError;
logger.error({
logCode: `${this.logCodePrefix}_websocket_error`,
extraInfo: {
errorMessage: error.name || error.message || 'Unknown error',
sfuComponent: this.sfuComponent,
}
}, 'WebSocket connection to SFU failed');
if (this.signallingTransportOpen) {
// 1301: "WEBSOCKET_DISCONNECTED", transport was already open
normalizedError = BaseBroker.assembleError(1301);
} else {
// 1302: "WEBSOCKET_CONNECTION_FAILED", transport errored before establishment
normalizedError = BaseBroker.assembleError(1302);
}
this.onerror(normalizedError);
return normalizedError;
}
openWSConnection () {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
this.ws.onmessage = this.onWSMessage.bind(this);
this.ws.onmessage = this._onWSMessage.bind(this);
this.ws.onclose = () => {
// 1301: "WEBSOCKET_DISCONNECTED",
this.onerror(BaseBroker.assembleError(1301));
};
this.ws.onerror = (error) => {
logger.error({
logCode: `${this.logCodePrefix}_websocket_error`,
extraInfo: {
errorMessage: error.name || error.message || 'Unknown error',
sfuComponent: this.sfuComponent,
}
}, 'WebSocket connection to SFU failed');
if (this.signallingTransportOpen) {
// 1301: "WEBSOCKET_DISCONNECTED", transport was already open
this.onerror(BaseBroker.assembleError(1301));
} else {
// 1302: "WEBSOCKET_CONNECTION_FAILED", transport errored before establishment
const normalized1302 = BaseBroker.assembleError(1302);
this.onerror(normalized1302);
return reject(normalized1302);
}
};
this.ws.onerror = (error) => reject(this._onWSError(error));
this.ws.onopen = () => {
this.pingInterval = setInterval(this.ping.bind(this), PING_INTERVAL_MS);
this.setupWSHeartbeat();
this.signallingTransportOpen = true;
return resolve();
};
});
}
closeWs() {
this.clearWSHeartbeat();
if (this.ws !== null) {
this.ws.onclose = function (){};
this.ws.close();
}
}
_updateLastMsgTime() {
this.ws.isAlive = true;
this.ws.lastMsgTime = Date.now();
}
_getTimeSinceLastMsg() {
return Date.now() - this.ws.lastMsgTime;
}
setupWSHeartbeat() {
if (WS_HEARTBEAT_OPTS.interval === 0 || this.ws == null) return;
this.ws.isAlive = true;
this.wsHeartbeat = setInterval(() => {
if (this.ws.isAlive === false) {
logger.warn({
logCode: `${this.logCodePrefix}_ws_heartbeat_failed`,
}, `WS heartbeat failed (${this.sfuComponent})`);
this.closeWs();
this._onWSError(new Error('HeartbeatFailed'));
return;
}
if (this._getTimeSinceLastMsg() < (
WS_HEARTBEAT_OPTS.interval - WS_HEARTBEAT_OPTS.delay
)) {
return;
}
this.ws.isAlive = false;
this.ping();
}, WS_HEARTBEAT_OPTS.interval);
this.ping();
}
clearWSHeartbeat() {
if (this.wsHeartbeat) {
clearInterval(this.wsHeartbeat);
}
}
sendMessage (message) {
const jsonMessage = JSON.stringify(message);
this.ws.send(jsonMessage);
try {
this.ws.send(jsonMessage);
} catch (error) {
logger.error({
logCode: `${this.logCodePrefix}_ws_send_error`,
extraInfo: {
errorName: error.name,
errorMessage: error.message,
sfuComponent: this.sfuComponent,
},
}, `Failed to send WebSocket message (${this.sfuComponent})`);
}
}
ping () {
@ -266,15 +347,7 @@ class BaseBroker {
this.webRtcPeer.peerConnection.onconnectionstatechange = null;
}
if (this.ws !== null) {
this.ws.onclose = function (){};
this.ws.close();
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
this.closeWs();
this.disposePeer();
this.started = false;

View File

@ -29,8 +29,12 @@ export default class WebRtcPeer extends EventEmitter2 {
this._outboundCandidateQueue = [];
this._inboundCandidateQueue = [];
this._waitForGatheringPromise = null;
this._waitForGatheringTimeout = null;
this._handleIceCandidate = this._handleIceCandidate.bind(this);
this._handleSignalingStateChange = this._handleSignalingStateChange.bind(this);
this._gatheringTimeout = this.options.gatheringTimeout;
this._assignOverrides();
}
@ -139,6 +143,48 @@ export default class WebRtcPeer extends EventEmitter2 {
}
}
waitForGathering(timeout = 0) {
if (timeout <= 0) return Promise.resolve();
if (this.isPeerConnectionClosed()) throw new Error('PeerConnection is closed');
if (this.peerConnection.iceGatheringState === 'complete') return Promise.resolve();
if (this._waitForGatheringPromise) return this._waitForGatheringPromise;
this._waitForGatheringPromise = new Promise((resolve) => {
this.once('candidategatheringdone', resolve);
this._waitForGatheringTimeout = setTimeout(() => {
this._emitCandidateGatheringDone();
}, timeout);
});
return this._waitForGatheringPromise;
}
_setRemoteDescription(rtcSessionDescription) {
if (this.isPeerConnectionClosed()) {
this.logger.error('BBB::WebRtcPeer::_setRemoteDescription - peer connection closed');
throw new Error('Peer connection is closed');
}
this.logger.debug('BBB::WebRtcPeer::_setRemoteDescription - setting remote description', rtcSessionDescription);
return this.peerConnection.setRemoteDescription(rtcSessionDescription);
}
_setLocalDescription(rtcSessionDescription) {
if (this.isPeerConnectionClosed()) {
this.logger.error('BBB::WebRtcPeer::_setLocalDescription - peer connection closed');
throw new Error('Peer connection is closed');
}
if (typeof this._gatheringTimeout === 'number' && this._gatheringTimeout > 0) {
this.logger.debug('BBB::WebRtcPeer::_setLocalDescription - setting description with gathering timer', rtcSessionDescription, this._gatheringTimeout);
return this.peerConnection.setLocalDescription(rtcSessionDescription)
.then(() => this.waitForGathering(this._gatheringTimeout));
}
this.logger.debug('BBB::WebRtcPeer::_setLocalDescription- setting description', rtcSessionDescription);
return this.peerConnection.setLocalDescription(rtcSessionDescription);
}
// Public method can be overriden via options
mediaStreamFactory() {
if (this.videoStream || this.audioStream) {
@ -194,19 +240,23 @@ export default class WebRtcPeer extends EventEmitter2 {
}
getLocalStream() {
if (this.localStream) {
return this.localStream;
}
if (this.peerConnection) {
this.localStream = new MediaStream();
if (this.localStream == null) this.localStream = new MediaStream();
const senders = this.peerConnection.getSenders();
const oldTracks = this.localStream.getTracks();
senders.forEach(({ track }) => {
if (track) {
if (track && !oldTracks.includes(track)) {
this.localStream.addTrack(track);
}
});
oldTracks.forEach((oldTrack) => {
if (!senders.some(({ track }) => track && track.id === oldTrack.id)) {
this.localStream.removeTrack(oldTrack);
}
});
return this.localStream;
}
@ -280,10 +330,10 @@ export default class WebRtcPeer extends EventEmitter2 {
switch (this.mode) {
case 'recvonly': {
const useAudio = this.mediaConstraints
&& ((typeof this.mediaConstraints.audio === 'boolean')
&& ((typeof this.mediaConstraints.audio === 'boolean' && this.mediaConstraints.audio)
|| (typeof this.mediaConstraints.audio === 'object'));
const useVideo = this.mediaConstraints
&& ((typeof this.mediaConstraints.video === 'boolean')
&& ((typeof this.mediaConstraints.video === 'boolean' && this.mediaConstraints.video)
|| (typeof this.mediaConstraints.video === 'object'));
if (useAudio) {
@ -330,7 +380,7 @@ export default class WebRtcPeer extends EventEmitter2 {
return this.peerConnection.createOffer()
.then((offer) => {
this.logger.debug('BBB::WebRtcPeer::generateOffer - created offer', offer);
return this.peerConnection.setLocalDescription(offer);
return this._setLocalDescription(offer);
})
.then(() => {
this._processEncodingOptions();
@ -346,14 +396,7 @@ export default class WebRtcPeer extends EventEmitter2 {
sdp,
});
if (this.isPeerConnectionClosed()) {
this.logger.error('BBB::WebRtcPeer::processAnswer - peer connection closed');
throw new Error('Peer connection is closed');
}
this.logger.debug('BBB::WebRtcPeer::processAnswer - setting remote description');
return this.peerConnection.setRemoteDescription(answer);
return this._setRemoteDescription(answer);
}
processOffer(sdp) {
@ -362,18 +405,11 @@ export default class WebRtcPeer extends EventEmitter2 {
sdp,
});
if (this.isPeerConnectionClosed()) {
this.logger.error('BBB::WebRtcPeer::processOffer - peer connection closed');
throw new Error('Peer connection is closed');
}
this.logger.debug('BBB::WebRtcPeer::processOffer - setting remote description', offer);
return this.peerConnection.setRemoteDescription(offer)
return this._setRemoteDescription(offer)
.then(() => this.peerConnection.createAnswer())
.then((answer) => {
this.logger.debug('BBB::WebRtcPeer::processOffer - created answer', answer);
return this.peerConnection.setLocalDescription(answer);
return this._setLocalDescription(answer);
})
.then(() => {
const localDescription = this.getLocalSessionDescriptor();
@ -404,6 +440,12 @@ export default class WebRtcPeer extends EventEmitter2 {
this._outboundCandidateQueue = [];
this.candidateGatheringDone = false;
if (this._waitForGatheringPromise) this._waitForGatheringPromise = null;
if (this._waitForGatheringTimeout) {
clearTimeout(this._waitForGatheringTimeout);
this._waitForGatheringTimeout = null;
}
} catch (error) {
this.logger.trace('BBB::WebRtcPeer::dispose - failed', error);
}

View File

@ -264,9 +264,18 @@ public:
enabled: true
kurento:
wsUrl: HOST
# Valid for video-provider. Time (ms) before its WS connection times out
# and tries to reconnect.
wsConnectionTimeout: 4000
cameraWsOptions:
# Valid for video-provider. Time (ms) before its WS connection times out
# and tries to reconnect.
wsConnectionTimeout: 4000
# maxRetries: max reconnection retries
maxRetries: 7
# debug: console trace logging for video-provider's ws
debug: false
heartbeat:
interval: 15000
delay: 3000
reconnectOnFailure: true
# Time in milis to wait for the browser to return a gUM call (used in video-preview)
gUMTimeout: 20000
# Controls whether ICE candidates should be signaled to bbb-webrtc-sfu.
@ -295,11 +304,13 @@ public:
bitrate: 1500
mediaTimeouts:
maxConnectionAttempts: 2
# Base screen media timeout (send|recv)
baseTimeout: 30000
# Max timeout: used as the max camera subscribe reconnection timeout. Each
# Base screen media timeout (send|recv) - first connections
baseTimeout: 20000
# Base screen media timeout (send|recv) - re-connections
baseReconnectionTimeout: 8000
# Max timeout: used as the max camera subscribe connection timeout. Each
# subscribe reattempt increases the reconnection timer up to this
maxTimeout: 60000
maxTimeout: 25000
timeoutIncreaseFactor: 1.5
constraints:
video:

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Es ist ein Fehler aufgetreten",
"app.error.fallback.presentation.description": "Er wurde protokolliert. Bitte versuchen, die Seite neu zu laden.",
"app.error.fallback.presentation.reloadButton": "Neu laden",
"app.guest.waiting": "Auf Erlaubnis zur Konferenzteilnahme wird gewartet",
"app.guest.errorSeeConsole": "Fehler: weitere Details in der Konsole.",
"app.guest.noModeratorResponse": "Keine Antwort vom Moderator.",
"app.guest.noSessionToken": "Kein Konferenz-Token erhalten.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Παρουσιάστηκε σφάλμα",
"app.error.fallback.presentation.description": "Καταγράφτηκε. Παρακαλούμε δοκιμάστε να ανανεώσετε τη σελίδα.",
"app.error.fallback.presentation.reloadButton": "Επαναφόρτωση",
"app.guest.waiting": "Αναμονή για έγκριση συμμετοχής",
"app.guest.errorSeeConsole": "Σφάλμα: δείτε περισσότερες πληροφορίες στην κονσόλα.",
"app.guest.noModeratorResponse": "Καμία απάντηση από διαχειριστή.",
"app.guest.noSessionToken": "Δεν έχει ληφθεί το αναγνωριστικό συνεδρίας.",

View File

@ -235,6 +235,8 @@
"app.presentation.presentationToolbar.fitToWidth": "Fit to width",
"app.presentation.presentationToolbar.fitToPage": "Fit to page",
"app.presentation.presentationToolbar.goToSlide": "Slide {0}",
"app.presentation.presentationToolbar.hideToolsDesc": "Hide Toolbars",
"app.presentation.presentationToolbar.showToolsDesc": "Show Toolbars",
"app.presentation.placeholder": "There is no currently active presentation",
"app.presentationUploder.title": "Presentation",
"app.presentationUploder.message": "As a presenter you have the ability to upload any office document or PDF file. We recommend PDF file for best results. Please ensure that a presentation is selected using the circle checkbox on the left hand side.",
@ -767,7 +769,6 @@
"app.error.fallback.presentation.title": "An error occurred",
"app.error.fallback.presentation.description": "It has been logged. Please try reloading the page.",
"app.error.fallback.presentation.reloadButton": "Reload",
"app.guest.waiting": "Waiting for approval to join",
"app.guest.errorSeeConsole": "Error: more details in the console.",
"app.guest.noModeratorResponse": "No response from Moderator.",
"app.guest.noSessionToken": "No session Token received.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Ha ocurrido un error",
"app.error.fallback.presentation.description": "Esto se ha registrado. Por favor, intente volver a cargar la página.",
"app.error.fallback.presentation.reloadButton": "Recargar",
"app.guest.waiting": "Esperando aprobación para unirse",
"app.guest.errorSeeConsole": "Error: más detalles en la consola.",
"app.guest.noModeratorResponse": "Sin respuesta del moderador.",
"app.guest.noSessionToken": "No se recibió ningún token de sesión.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Ocurrió un error",
"app.error.fallback.presentation.description": "Ha sido registrado. Por favor, intente volver a cargar la página.",
"app.error.fallback.presentation.reloadButton": "Recargar",
"app.guest.waiting": "Esperando aprobación para unirse",
"app.guest.errorSeeConsole": "Error: más detalles en la consola.",
"app.guest.noModeratorResponse": "Sin respuesta del moderador.",
"app.guest.noSessionToken": "No se recibió ningún token de sesión.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Esines viga",
"app.error.fallback.presentation.description": "Viga on registreeritud. Palun proovi leht uuesti laadida.",
"app.error.fallback.presentation.reloadButton": "Laadi uuesti",
"app.guest.waiting": "Ootame ühendamiseks nõusolekut",
"app.guest.errorSeeConsole": "Viga: täpsemad üksikasjad konsoolil.",
"app.guest.noModeratorResponse": "Pole vastust moderaatorilt.",
"app.guest.noSessionToken": "Pole kätte saanud sessioonitõendit.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Errore bat gertatu da",
"app.error.fallback.presentation.description": "Saioa hasi da. Saiatu orria berriro kargatzen.",
"app.error.fallback.presentation.reloadButton": "Birkargatu",
"app.guest.waiting": "Sartzeko onarpenaren zain",
"app.guest.errorSeeConsole": "Errorea: xehetasun gehiago kontsolan",
"app.guest.noModeratorResponse": "Moderatzaileak ez du erantzuten.",
"app.guest.noSessionToken": "Ez da saio-tokenik jaso.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "خطایی رخ داد",
"app.error.fallback.presentation.description": "این مورد ثبت شد. لطفا صفحه را دوباره بارگیری کنید.",
"app.error.fallback.presentation.reloadButton": "بارگیری دوباره",
"app.guest.waiting": "در انتظار تأیید برای پیوستن",
"app.guest.errorSeeConsole": "خطا: جزئیات بیشتر در کنسول.",
"app.guest.noModeratorResponse": "هیچ پاسخی از مدیر وجود ندارد",
"app.guest.noSessionToken": "هیچ توکن جلسه‌ای دریافت نشد.",

View File

@ -122,7 +122,7 @@
"app.userList.menu.removeConfirmation.label": "Retirer l'utilisateur ({0})",
"app.userlist.menu.removeConfirmation.desc": "Empêcher cet utilisateur de rejoindre la réunion à nouveau.",
"app.userList.menu.muteUserAudio.label": "Rendre l'utilisateur silencieux",
"app.userList.menu.unmuteUserAudio.label": "Autoriser l'utilisateur à parler",
"app.userList.menu.unmuteUserAudio.label": "Permettre l'activation du microphone",
"app.userList.menu.webcamPin.label": "Épingler la caméra de ce participant",
"app.userList.menu.webcamUnpin.label": "Désépingler la caméra de ce participant",
"app.userList.menu.giveWhiteboardAccess.label" : "Donner l'accès au tableau blanc",
@ -334,9 +334,9 @@
"app.poll.secretPoll.label" : "Sondage Anonyme",
"app.poll.secretPoll.isSecretLabel": "Le sondage est anonyme - vous ne pourrez pas voir les réponses individuelles.",
"app.poll.questionErr": "Vous devez soumettre une question.",
"app.poll.optionErr": "Entrez une option de sondage",
"app.poll.optionErr": "Renseignez une option de sondage",
"app.poll.startPollDesc": "Commencer le sondage",
"app.poll.showRespDesc": "Affiche la configuration de la réponse",
"app.poll.showRespDesc": "Affiche la configuration des réponses",
"app.poll.addRespDesc": "Ajoute une option de réponse au sondage",
"app.poll.deleteRespDesc": "Supprime l'option {0}",
"app.poll.t": "Vrai",
@ -363,7 +363,7 @@
"app.poll.liveResult.usersTitle": "Utilisateurs",
"app.poll.liveResult.responsesTitle": "Réponse",
"app.poll.liveResult.secretLabel": "Ceci est un sondage anonyme. Les réponses individuelles ne seront pas affichées.",
"app.poll.removePollOpt": "L'option a été retirée du sondage {0} ",
"app.poll.removePollOpt": "L'option a été retirée du sondage {0}",
"app.poll.emptyPollOpt": "Vide",
"app.polling.pollingTitle": "Options du sondage",
"app.polling.pollQuestionTitle": "Question du sondage",
@ -373,10 +373,10 @@
"app.polling.responseSecret": "Sondage anonyme - le présentateur ne vois pas qui répond.",
"app.polling.responseNotSecret": "Sondage normal - le présentateur peut voir votre réponse.",
"app.polling.pollAnswerLabel": "Réponse au sondage {0}",
"app.polling.pollAnswerDesc": "Choisir cette option pour voter {0}",
"app.polling.pollAnswerDesc": "Choisir cette option afin de voter pour {0}",
"app.failedMessage": "Problème de connexion au serveur, toutes nos excuses.",
"app.downloadPresentationButton.label": "Télécharger la présentation d'origine",
"app.connectingMessage": "Connexion...",
"app.connectingMessage": "Connexion en cours...",
"app.waitingMessage": "Déconnecté. Essayez de vous reconnecter dans {0} secondes...",
"app.retryNow": "Réessayer maintenant",
"app.muteWarning.label": "Cliquez sur {0} pour réactiver votre micro.",
@ -391,7 +391,7 @@
"app.navBar.settingsDropdown.fullscreenDesc": "Passer le menu de paramétrage en plein écran",
"app.navBar.settingsDropdown.settingsDesc": "Modifier les paramètres généraux",
"app.navBar.settingsDropdown.aboutDesc": "Afficher les informations du client",
"app.navBar.settingsDropdown.leaveSessionDesc": "Quitter la conférence",
"app.navBar.settingsDropdown.leaveSessionDesc": "Quitter la réunion",
"app.navBar.settingsDropdown.exitFullscreenDesc": "Quitter le mode plein écran",
"app.navBar.settingsDropdown.hotkeysLabel": "Raccourcis clavier",
"app.navBar.settingsDropdown.hotkeysDesc": "Liste des raccourcis clavier disponibles",
@ -400,20 +400,20 @@
"app.navBar.settingsDropdown.helpDesc": "Renvoie l'utilisateur vers des tutoriels vidéos (ouvre un nouvel onglet)",
"app.navBar.settingsDropdown.endMeetingDesc": "Interrompt la réunion en cours",
"app.navBar.settingsDropdown.endMeetingLabel": "Mettre fin à la réunion",
"app.navBar.userListToggleBtnLabel": "Affichage de la liste des utilisateurs",
"app.navBar.toggleUserList.ariaLabel": "Affichage des utilisateurs et des messages",
"app.navBar.userListToggleBtnLabel": "Masquer ou afficher la liste des utilisateurs",
"app.navBar.toggleUserList.ariaLabel": "Afficher les utilisateurs ou les messages",
"app.navBar.toggleUserList.newMessages": "avec notification des nouveaux messages",
"app.navBar.toggleUserList.newMsgAria": "Nouveau message de {0}",
"app.navBar.recording": "Cette session est enregistrée",
"app.navBar.recording.on": "Enregistrement en cours",
"app.navBar.recording.off": "Pas d'enregistrement en cours",
"app.navBar.emptyAudioBrdige": "Le microphone n'est pas actif. Partagez votre microphone pour ajouter du son à cet enregistrement.",
"app.navBar.emptyAudioBrdige": "Le microphone n'est pas actif. Activez votre microphone pour ajouter du son à cet enregistrement.",
"app.leaveConfirmation.confirmLabel": "Quitter",
"app.leaveConfirmation.confirmDesc": "Vous déconnecte de la conférence",
"app.leaveConfirmation.confirmDesc": "Vous déconnecte de la réunion",
"app.endMeeting.title": "Mettre fin à {0}",
"app.endMeeting.description": "Cette action mettra fin à la séance pour {0} utilisateurs(s) actif(s). Êtes-vous sûr de vouloir mettre fin à cette séance ?",
"app.endMeeting.noUserDescription": "Êtes-vous sûr de vouloir mettre fin à la séance ?",
"app.endMeeting.contentWarning": "Les messages de discussion, les notes partagées, le contenu du tableau blanc et les documents partagés lors de cette séance ne seront plus accessibles directement ",
"app.endMeeting.contentWarning": "Les messages de discussion, les notes partagées, le contenu du tableau blanc et les documents partagés lors de cette séance ne seront plus directement accessibles",
"app.endMeeting.yesLabel": "Mettre fin à la session pour tous les utilisateurs",
"app.endMeeting.noLabel": "Non",
"app.about.title": "À propos",
@ -423,7 +423,7 @@
"app.about.confirmLabel": "OK",
"app.about.confirmDesc": "OK",
"app.about.dismissLabel": "Annuler",
"app.about.dismissDesc": "Fermer l'information client",
"app.about.dismissDesc": "Fermer la note à propos du client",
"app.mobileAppModal.title": "Ouvrir l'application mobile de BigBlueButton",
"app.mobileAppModal.description": "L'application mobile de BigBlueButton est-elle installée sur votre appareil ?",
"app.mobileAppModal.openApp": "Oui, ouvrir l'application maintenant",
@ -435,12 +435,12 @@
"app.mobileAppModal.userConnectedWithSameId": "L'utilisateur {0} vient de se connecter en utilisant le même identifiant que vous",
"app.actionsBar.changeStatusLabel": "Changer de statut",
"app.actionsBar.muteLabel": "Rendre silencieux",
"app.actionsBar.unmuteLabel": "Autoriser à parler",
"app.actionsBar.unmuteLabel": "Permettre l'activation du microphone",
"app.actionsBar.camOffLabel": "Caméra éteinte",
"app.actionsBar.raiseLabel": "Lever la main",
"app.actionsBar.label": "Barre d'actions",
"app.actionsBar.actionsDropdown.restorePresentationLabel": "Rétablir la présentation",
"app.actionsBar.actionsDropdown.restorePresentationDesc": "Bouton pour rétablir la fenêtre de présentation après qu'elle ait été réduite",
"app.actionsBar.actionsDropdown.restorePresentationDesc": "Bouton pour rétablir la fenêtre de présentation après qu'elle ait été réduite",
"app.actionsBar.actionsDropdown.minimizePresentationLabel": "Réduire la fenêtre de présentation",
"app.actionsBar.actionsDropdown.minimizePresentationDesc": "Bouton utilisé pour réduire la fenêtre de présentation",
"app.actionsBar.actionsDropdown.layoutModal": "Fenêtre de paramétrage de la mise en page",
@ -456,7 +456,7 @@
"app.submenu.application.languageLabel": "Langue de l'application",
"app.submenu.application.languageOptionLabel": "Choisir la langue",
"app.submenu.application.noLocaleOptionLabel": "Aucune langue d'application détectée",
"app.submenu.application.paginationEnabledLabel": "Pagination de la vidéo",
"app.submenu.application.paginationEnabledLabel": "Mise en page de la vidéo",
"app.submenu.application.layoutOptionLabel": "Type de mise en page",
"app.submenu.application.pushLayoutLabel": "Appliquer la mise en page",
"app.submenu.application.localeDropdown.af": "Afrikaans",
@ -526,15 +526,15 @@
"app.submenu.notification.audioAlertLabel": "Alerte sonore",
"app.submenu.notification.pushAlertLabel": "Message d'alerte",
"app.submenu.notification.messagesLabel": "Fil de discussion",
"app.submenu.notification.userJoinLabel": "L'utilisateur a rejoint la réunion",
"app.submenu.notification.userLeaveLabel": "L'utilisateur a quitté la réunion",
"app.submenu.notification.guestWaitingLabel": "Invité en attente d'approbation pour accéder",
"app.submenu.notification.userJoinLabel": "L'utilisateur rejoint la réunion",
"app.submenu.notification.userLeaveLabel": "L'utilisateur quitte la réunion",
"app.submenu.notification.guestWaitingLabel": "Invité en attente d'approbation",
"app.submenu.audio.micSourceLabel": "Choix du micro",
"app.submenu.audio.speakerSourceLabel": "Choix du haut-parleur",
"app.submenu.audio.streamVolumeLabel": "Volume de votre flux audio",
"app.submenu.video.title": "Vidéo",
"app.submenu.video.videoSourceLabel": "Choix de la source pour l'affichage",
"app.submenu.video.videoOptionLabel": "Choisir la source pour l'affichage",
"app.submenu.video.videoSourceLabel": "Choix de la source vidéo",
"app.submenu.video.videoOptionLabel": "Choisir la source vidéo",
"app.submenu.video.videoQualityLabel": "Qualité de la vidéo",
"app.submenu.video.qualityOptionLabel": "Choisissez la qualité de la vidéo",
"app.submenu.video.participantsCamLabel": "Voir les webcams des participants",
@ -547,7 +547,7 @@
"app.settings.main.cancel.label.description": "Annule les changements et ferme le menu des paramètres",
"app.settings.main.save.label": "Enregistrer",
"app.settings.main.save.label.description": "Enregistre les changements et ferme le menu des paramètres",
"app.settings.dataSavingTab.label": "Économies de données",
"app.settings.dataSavingTab.label": "Gestion du débit",
"app.settings.dataSavingTab.webcam": "Activer les webcams des autres participants",
"app.settings.dataSavingTab.screenShare": "Activer le partage d'écran des autres participants",
"app.settings.dataSavingTab.description": "Pour économiser votre bande passante, ajustez l'affichage actuel.",
@ -558,8 +558,8 @@
"app.statusNotifier.raisedHandDesc": "{0} ont levé la main",
"app.statusNotifier.raisedHandDescOneUser": "{0} a levé la main",
"app.statusNotifier.and": "et",
"app.switch.onLabel": "ON",
"app.switch.offLabel": "OFF",
"app.switch.onLabel": "Allumé",
"app.switch.offLabel": "Éteint",
"app.talkingIndicator.ariaMuteDesc" : "Sélectionner pour rendre l'utilisateur silencieux",
"app.talkingIndicator.isTalking" : "{0} est en train de parler",
"app.talkingIndicator.moreThanMaxIndicatorsTalking" : "{0}+ parlent",
@ -567,16 +567,16 @@
"app.talkingIndicator.wasTalking" : "{0} a cessé de parler",
"app.actionsBar.actionsDropdown.actionsLabel": "Actions",
"app.actionsBar.actionsDropdown.presentationLabel": "Charger/Gérer les documents de présentation",
"app.actionsBar.actionsDropdown.initPollLabel": "Préparer un sondage",
"app.actionsBar.actionsDropdown.initPollLabel": "Lancer un sondage",
"app.actionsBar.actionsDropdown.desktopShareLabel": "Partager votre écran",
"app.actionsBar.actionsDropdown.stopDesktopShareLabel": "Cesser le partage d'écran",
"app.actionsBar.actionsDropdown.presentationDesc": "Charger votre présentation",
"app.actionsBar.actionsDropdown.initPollDesc": "Préparer un sondage",
"app.actionsBar.actionsDropdown.initPollDesc": "Lancer un sondage",
"app.actionsBar.actionsDropdown.desktopShareDesc": "Partager votre écran avec les autres participants",
"app.actionsBar.actionsDropdown.stopDesktopShareDesc": "Cesser de partager votre écran ",
"app.actionsBar.actionsDropdown.pollBtnLabel": "Lancer un sondage",
"app.actionsBar.actionsDropdown.pollBtnLabel": "Démarrer un sondage",
"app.actionsBar.actionsDropdown.pollBtnDesc": "Affiche/cache le volet de sondage",
"app.actionsBar.actionsDropdown.saveUserNames": "Sauvegarder les noms d'utilisateur",
"app.actionsBar.actionsDropdown.saveUserNames": "Sauvegarder les noms des utilisateurs",
"app.actionsBar.actionsDropdown.createBreakoutRoom": "Créer des salles pour les groupes de travail",
"app.actionsBar.actionsDropdown.createBreakoutRoomDesc": "Créer des groupes de travail pour scinder la réunion en cours",
"app.actionsBar.actionsDropdown.captionsLabel": "Écrire des sous-titres SME",
@ -587,20 +587,20 @@
"app.actionsBar.actionsDropdown.selectRandUserDesc": "Sélectionne aléatoirement un utilisateur parmi les participants disponibles",
"app.actionsBar.actionsDropdown.propagateLayoutLabel": "Diffuser la mise en page",
"app.actionsBar.emojiMenu.statusTriggerLabel": "Définir votre statut",
"app.actionsBar.emojiMenu.awayLabel": "Éloigné",
"app.actionsBar.emojiMenu.awayDesc": "Passer votre statut à « éloigné »",
"app.actionsBar.emojiMenu.awayLabel": "Absent",
"app.actionsBar.emojiMenu.awayDesc": "Passer votre statut à « absent »",
"app.actionsBar.emojiMenu.raiseHandLabel": "Lever la main",
"app.actionsBar.emojiMenu.lowerHandLabel": "Abaisser la main",
"app.actionsBar.emojiMenu.raiseHandDesc": "Lever la main pour poser une question",
"app.actionsBar.emojiMenu.neutralLabel": "Indécis",
"app.actionsBar.emojiMenu.neutralDesc": "Passer votre statut à « indécis »",
"app.actionsBar.emojiMenu.confusedLabel": "Désorienté",
"app.actionsBar.emojiMenu.confusedDesc": "Passer votre statut à « désorienté »",
"app.actionsBar.emojiMenu.confusedLabel": "Troublé",
"app.actionsBar.emojiMenu.confusedDesc": "Passer votre statut à « troublé »",
"app.actionsBar.emojiMenu.sadLabel": "Triste",
"app.actionsBar.emojiMenu.sadDesc": "Passer votre statut à « triste »",
"app.actionsBar.emojiMenu.happyLabel": "Ravi",
"app.actionsBar.emojiMenu.happyDesc": "Passer votre statut à « ravi »",
"app.actionsBar.emojiMenu.noneLabel": "Effacer votre statut",
"app.actionsBar.emojiMenu.noneLabel": "Effacer le statut",
"app.actionsBar.emojiMenu.noneDesc": "Effacer votre statut",
"app.actionsBar.emojiMenu.applauseLabel": "Applaudissements",
"app.actionsBar.emojiMenu.applauseDesc": "Passer votre statut à « applaudissements »",
@ -609,30 +609,30 @@
"app.actionsBar.emojiMenu.thumbsDownLabel": "Défavorable",
"app.actionsBar.emojiMenu.thumbsDownDesc": "Passer votre statut à « défavorable »",
"app.actionsBar.currentStatusDesc": "statut actuel {0}",
"app.actionsBar.captions.start": "Démarrer l'affichage des sous-titres",
"app.actionsBar.captions.start": "Démarrer l'affichage des sous-titres SEM",
"app.actionsBar.captions.stop": "Arrêter l'affichage des sous-titres SEM",
"app.audioNotification.audioFailedError1001": "WebSocket déconnecté (erreur 1001)",
"app.audioNotification.audioFailedError1002": "Échec de la connexion WebSocket (erreur 1002)",
"app.audioNotification.audioFailedError1003": "Version du navigateur non supportée (erreur 1003)",
"app.audioNotification.audioFailedError1003": "Version du navigateur incompatible (erreur 1003)",
"app.audioNotification.audioFailedError1004": "Échec lors de l'appel (raison={0}) (erreur 1004)",
"app.audioNotification.audioFailedError1005": "L'appel s'est terminé de façon inattendue (erreur 1005)",
"app.audioNotification.audioFailedError1006": "Délai d'appel dépassé (erreur 1006)",
"app.audioNotification.audioFailedError1007": "Échec de la connexion (erreur ICE 1007)",
"app.audioNotification.audioFailedError1008": "Échec du transfert (erreur 1008)",
"app.audioNotification.audioFailedError1009": "impossible de récupérer les informations du serveur STUN/TURN (erreur 1009)",
"app.audioNotification.audioFailedError1010": "Délai dépassé durant la négociation (erreur ICE 1010)",
"app.audioNotification.audioFailedError1010": "Délai dépassé durant la négociation pour la connexion (erreur ICE 1010)",
"app.audioNotification.audioFailedError1011": "Délai d'attente de connexion dépassé (erreur ICE 1011)",
"app.audioNotification.audioFailedError1012": "Connexion fermée (erreur ICE 1012)",
"app.audioNotification.audioFailedMessage": "Votre connexion en mode audio a échoué",
"app.audioNotification.mediaFailedMessage": "getUserMicMedia a échoué car seules les origines sécurisées sont autorisées",
"app.audioNotification.deviceChangeFailed": "Le changement d'appareil audio a échoué. Vérifiez que l'appareil est bien installé et disponible.",
"app.audioNotification.mediaFailedMessage": "getUserMicMedia a échoué car seules les sources sécurisées sont autorisées",
"app.audioNotification.deviceChangeFailed": "Le changement d'équipement audio a échoué. Vérifiez que l'appareil est bien installé et disponible.",
"app.audioNotification.closeLabel": "Fermer",
"app.audioNotificaion.reconnectingAsListenOnly": "Le microphone est verrouillé pour les participants, vous êtes connecté en mode écoute uniquement.",
"app.audioNotificaion.reconnectingAsListenOnly": "Le microphone est verrouillé pour les participants, vous êtes connecté uniquement en mode auditeur.",
"app.breakoutJoinConfirmation.title": "Rejoindre le groupe de travail",
"app.breakoutJoinConfirmation.message": "Voulez-vous rejoindre la séance",
"app.breakoutJoinConfirmation.confirmDesc": "Rejoignez le groupe de travail",
"app.breakoutJoinConfirmation.dismissLabel": "Annuler",
"app.breakoutJoinConfirmation.dismissDesc": "Fermer et refuser de rejoindre le groupe de travail",
"app.breakoutJoinConfirmation.dismissDesc": "Ferme et rejette l'intégration au groupe de travail",
"app.breakoutJoinConfirmation.freeJoinMessage": "Choisissez une groupe de travail à rejoindre",
"app.breakoutTimeRemainingMessage": "Temps restant pour le groupe de travail : {0}",
"app.breakoutWillCloseMessage": "Le temps s'est écoulé. La groupe de travail fermera bientôt",
@ -641,12 +641,12 @@
"app.breakout.dropdown.options": "Options des groupes de travail",
"app.breakout.dropdown.manageUsers": "Gestion des utilisateurs",
"app.calculatingBreakoutTimeRemaining": "Calcul du temps restant...",
"app.audioModal.ariaTitle": "Fenêtre modale pour joindre la réunion en audio",
"app.audioModal.microphoneLabel": "Microphone",
"app.audioModal.ariaTitle": "Choix du mode audio pour rejoindre la réunion",
"app.audioModal.microphoneLabel": "Parler et écouter",
"app.audioModal.listenOnlyLabel": "Écoute seule",
"app.audioModal.microphoneDesc": "Rejoint la réunion audio en utilisant un micro",
"app.audioModal.listenOnlyDesc": "Rejoint la réunion audio en écoute seule",
"app.audioModal.audioChoiceLabel": "Comment désirez-vous rejoindre la réunion audio ?",
"app.audioModal.microphoneDesc": "Rejoint la réunion en utilisant un micro",
"app.audioModal.listenOnlyDesc": "Rejoint la réunion en écoute seule",
"app.audioModal.audioChoiceLabel": "Comment désirez-vous rejoindre la réunion ?",
"app.audioModal.iOSBrowser": "Audio / Vidéo non pris en charge",
"app.audioModal.iOSErrorDescription": "Actuellement l'audio et la vidéo ne sont pas pris en charge par Chrome sur iOS.",
"app.audioModal.iOSErrorRecommendation": "Nous recommandons d'utiliser Safari iOS.",
@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Une erreur s'est produite",
"app.error.fallback.presentation.description": "Cela a été consigné. Essayez de recharger la page s'il vous plaît.",
"app.error.fallback.presentation.reloadButton": "Recharger",
"app.guest.waiting": "En attente de l'approbation pour rejoindre la séance",
"app.guest.errorSeeConsole": "Erreur: plus de détail dans la console",
"app.guest.noModeratorResponse": "Aucune réponse du modérateur.",
"app.guest.noSessionToken": "Aucun jeton de session reçu.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Produciuse un erro",
"app.error.fallback.presentation.description": "Accedeu. Tente volver cargar a páxina.",
"app.error.fallback.presentation.reloadButton": "Recargar",
"app.guest.waiting": "Agardando a aprobación para unirse",
"app.guest.errorSeeConsole": "Erro: máis detalles na consola.",
"app.guest.noModeratorResponse": "Non hai resposta do moderador.",
"app.guest.noSessionToken": "Non se recibiu ningún testemuño de sesión.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Hiba lépett fel",
"app.error.fallback.presentation.description": "Naplóztuk. Kérjük, próbáld újra betölteni az oldalt.",
"app.error.fallback.presentation.reloadButton": "Újratöltése",
"app.guest.waiting": "Várakozás jóváhagyásra",
"app.guest.errorSeeConsole": "Hiba: további részletek a konzolban.",
"app.guest.noModeratorResponse": "A szervező elfoglalt. Kérlek, kíséreld meg a csatlakozást kicsit később!",
"app.guest.noSessionToken": "Nem érkezett munkamenet Token.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Տեղի է ունեցել սխալ",
"app.error.fallback.presentation.description": "Գործողությունը գրանցվել է Խնդրում ենք վերբեռնել /Refresh/ էջը",
"app.error.fallback.presentation.reloadButton": "Վերբեռնել",
"app.guest.waiting": "Մուտքի թույլտվության սպասում",
"app.guest.errorSeeConsole": "Ошибка: Более подробная информация в консоли.",
"app.guest.noModeratorResponse": "Մոդերատորից պատասխան չի ստացվել",
"app.guest.noSessionToken": "Սեանսի թոքեն չի ստացվել",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "エラーが発生しました",
"app.error.fallback.presentation.description": "記録されました。ページを再読み込みしてください。",
"app.error.fallback.presentation.reloadButton": "再読み込み",
"app.guest.waiting": "主催者の承認待ち",
"app.guest.errorSeeConsole": "エラー:詳細はコンソールに表示。",
"app.guest.noModeratorResponse": "司会者からの反応がありません。",
"app.guest.noSessionToken": "会議の参加許可が得られていません。",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Произошла ошибка",
"app.error.fallback.presentation.description": "Действие было зарегистрировано. Пожалуйста, попробуйте перезагрузить страницу",
"app.error.fallback.presentation.reloadButton": "Перезагрузить",
"app.guest.waiting": "Ожидание одобрения входа",
"app.guest.errorSeeConsole": "Ошибка: Более подробная информация в консоли.",
"app.guest.noModeratorResponse": "Нет ответа от Модератора.",
"app.guest.noSessionToken": "Токен вебинара не получен.",

View File

@ -767,7 +767,6 @@
"app.error.fallback.presentation.title": "Bir sorun çıktı",
"app.error.fallback.presentation.description": "Oturum açıldı. Lütfen sayfayı yeniden yüklemeyi deneyin.",
"app.error.fallback.presentation.reloadButton": "Yeniden yükle",
"app.guest.waiting": "Katılma onayı bekleniyor",
"app.guest.errorSeeConsole": "Hata: Konsoldan ayrıntılı bilgi alabilirsiniz.",
"app.guest.noModeratorResponse": "Sorumlu yanıt vermedi.",
"app.guest.noSessionToken": "Herhangi bir oturum kodu alınmadı.",

3
bigbluebutton-tests/gns3/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
NPDC
bbb-dev-ca.crt
bbb-dev-ca.key

View File

@ -0,0 +1 @@
git clone --branch master --depth 1 https://github.com/BrentBaccala/NPDC NPDC

View File

@ -35,11 +35,10 @@ Some special names are defined. Requesting a device name starting with `testcli
## Usage
1. You'll need several tools from Brent Baccala's NPDC repository on github, which is a submodule in the NPDC directory, so run this command to fetch it:
1. You'll need several tools from Brent Baccala's NPDC repository on github, so run this command to fetch it:
```
git submodule init
git submodule update
./NPDC.placeholder.sh
```
1. Read, understand, and run the `install-gns3.sh` script in `NPDC/GNS3`

View File

@ -28,9 +28,9 @@ $ npm test
You can also run a single test suite and limit the execution to only one browser:
```bash
$ npx playwright test chat --browser=firefox
$ npx playwright test chat --project="firefox"
or
$ npm test chat -- --browser=firefox
$ npm test chat -- --project="firefox" # or "chromium" for example
```
#### Additional commands

View File

@ -0,0 +1,50 @@
const chromiumConfig = {
name: 'Chromium',
use: {
browserName: 'chromium',
launchOptions: {
args: [
'--no-sandbox',
'--ignore-certificate-errors',
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
'--allow-file-access-from-files',
],
},
},
};
const firefoxConfig = {
name: 'Firefox',
use: {
browserName: 'firefox',
launchOptions: {
args: [
'--quiet',
'--use-test-media-devices',
],
firefoxUserPrefs: {
"media.navigator.streams.fake": true,
"media.navigator.permission.disabled": true,
}
},
},
};
const webkitConfig = {
name: 'WebKit',
use: {
browserName: 'webkit',
launchOptions: {
args: [
'--no-sandbox',
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
]
},
},
};
exports.chromiumConfig = chromiumConfig;
exports.firefoxConfig = firefoxConfig;
exports.webkitConfig = webkitConfig;

View File

@ -336,6 +336,7 @@ exports.connectionStatusItemUser = 'div[data-test="connectionStatusItemUser"]';
exports.connectionStatusLinkToSettings = `${networkDataContainer} span[role="button"]`;
exports.dataSavingWebcams = 'input[data-test="dataSavingWebcams"]';
exports.connectionStatusOfflineUser = 'div[data-test="offlineUser"]';
exports.videoUploadRateData = 'div[data-test="videoUploadRateData"]';
exports.connectionDataContainer = networkDataContainer;
exports.avatarsWrapperAvatar = 'div[data-test="avatarsWrapperAvatar"]';
exports.guestPolicyLabel = 'li[data-test="guestPolicyLabel"]';
@ -398,7 +399,19 @@ exports.startSharingWebcam = 'button[data-test="startSharingWebcam"]';
exports.webcamConnecting = 'div[data-test="webcamConnecting"]';
exports.webcamContainer = 'video[data-test="videoContainer"]';
exports.webcamVideoItem = 'div[data-test="webcamVideoItem"]';
exports.videoDropdownMenu = 'button[data-test="videoDropdownMenu"]';
exports.advancedVideoSettingsBtn = 'li[data-test="advancedVideoSettingsButton"]';
exports.mirrorWebcamBtn = 'li[data-test="mirrorWebcamBtn"]';
exports.focusWebcamBtn = 'li[data-test="focusWebcamBtn"]';
exports.pinWebcamBtn = 'li[data-test="pinWebcamBtn"]';
exports.webcamFullscreenButton = 'button[data-test="webcamFullscreenButton"]';
exports.selectDefaultBackground = 'button[data-test="selectDefaultBackground"]';
exports.selectCustomBackground = 'button[data-test="selectCustomBackground"]';
exports.removeCustomBackground = 'button[data-test="removeCustomBackground"]';
exports.inputBackgroundButton = 'button[data-test="inputBackgroundButton"]';
exports.noneBackgroundButton = 'button[data-test="noneBackgroundButton"]';
exports.videoQualitySelector = 'select[id="setQuality"]';
exports.webcamItemTalkingUser = 'div[data-test="webcamItemTalkingUser"]';
exports.webcamSettingsModal = 'div[data-test="webcamSettingsModal"]';
exports.dropdownWebcamButton = 'div[data-test="dropdownWebcamButton"]';
@ -458,7 +471,7 @@ exports.cameraDock = 'video[data-test="videoContainer"]';
// Font size
exports.increaseFontSize = 'button[data-test="increaseFontSize"]';
exports.descreaseFontSize = 'button[data-test="decreaseFontSize"]';
exports.decreaseFontSize = 'button[data-test="decreaseFontSize"]';
// Learning dashboard
exports.learningDashboard = 'li[data-test="learningDashboard"]';
@ -477,3 +490,12 @@ exports.pollYesNoAnswer = 'div[role="cell"]:nth-child(5)';
exports.pollUserResponseQuestion = 'div[role="rowgroup"] div:nth-child(6) div';
exports.pollUserResponseAnswer = 'div[role="cell"]:nth-child(6)';
exports.pollTotal = 'div[role="cell"]:nth-child(2)';
exports.meetingStatusActiveDashboard = 'span[data-test="meetingStatusActiveDashboard"]';
exports.meetingDurationTimeDashboard = 'p[data-test="meetingDurationTimeDashboard"]';
exports.userNameDashboard = 'td[data-test="userLabelDashboard"] button';
exports.userWebcamTimeDashboard = 'td[data-test="userWebcamTimeDashboard"]';
exports.userRaiseHandDashboard = 'td[data-test="userRaiseHandDashboard"]';
exports.userStatusDashboard = 'td[data-test="userStatusDashboard"]';
exports.userActivityScoreDashboard = 'td[data-test="userActivityScoreDashboard"]';
exports.activityScorePanelDashboard = 'button[data-test="activityScorePanelDashboard"]';
exports.downloadSessionLearningDashboard = 'button[data-test="downloadSessionDataDashboard"]';

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@ -5,4 +5,4 @@ module.exports = exports = {
fullName: 'User1',
moderatorPW: 'mp',
attendeePW: 'ap',
};
};

View File

@ -19,6 +19,10 @@ function checkElementLengthEqualTo([element, count]) {
return document.querySelectorAll(element).length == count;
}
function getElementLength(element) {
return document.querySelectorAll(element).length;
}
// Text
async function checkTextContent(baseContent, checkData) {
if (typeof checkData === 'string') checkData = new Array(checkData);
@ -38,5 +42,6 @@ function constructClipObj(wbBox) {
exports.checkElement = checkElement;
exports.checkElementLengthEqualTo = checkElementLengthEqualTo;
exports.getElementLength = getElementLength;
exports.checkTextContent = checkTextContent;
exports.constructClipObj = constructClipObj;

View File

@ -5,7 +5,8 @@ const { expect } = require("@playwright/test");
const Page = require("../core/page");
const { sleep } = require("../core/helpers");
const { ELEMENT_WAIT_EXTRA_LONG_TIME } = require("../core/constants");
const { openPoll, timeInSeconds } = require("./util");
const { openPoll, timeInSeconds, rowFilter } = require("./util");
const { checkTextContent } = require('../core/util');
class LearningDashboard extends MultiUsers {
constructor(browser, context) {
@ -93,6 +94,8 @@ class LearningDashboard extends MultiUsers {
//Checks
await this.dashboardPage.reloadPage();
const activityScore = await rowFilter(this.dashboardPage, 'tr', /Attendee/, e.userActivityScoreDashboard);
await expect(activityScore).toHaveText(/2/, { timeout: ELEMENT_WAIT_EXTRA_LONG_TIME });
await this.dashboardPage.waitAndClick(e.pollPanel);
await this.dashboardPage.hasText(e.pollTotal, '4', ELEMENT_WAIT_EXTRA_LONG_TIME);
@ -112,6 +115,70 @@ class LearningDashboard extends MultiUsers {
await this.dashboardPage.hasText(e.pollUserResponseQuestion, 'User response?');
await this.dashboardPage.hasText(e.pollUserResponseAnswer, e.answerMessage);
}
async basicInfos() {
// Meeting Status check
await this.dashboardPage.hasText(e.meetingStatusActiveDashboard, 'Active');
await this.dashboardPage.reloadPage();
// Meeting Time Duration check
const timeLocator = this.dashboardPage.getLocator(e.meetingDurationTimeDashboard);
const timeContent = await (timeLocator).textContent();
const array = timeContent.split(':').map(Number);
const firstTime = array[1] * 3600 + array[2] * 60 + array[3];
await sleep(5000);
await this.dashboardPage.reloadPage();
const timeContentGreater = await (timeLocator).textContent();
const arrayGreater = timeContentGreater.split(':').map(Number);
const secondTime = arrayGreater[1] * 3600 + arrayGreater[2] * 60 + arrayGreater[3];
await expect(secondTime).toBeGreaterThan(firstTime);
}
async overview() {
await this.modPage.waitAndClick(e.joinVideo);
await this.modPage.waitAndClick(e.startSharingWebcam);
await this.modPage.waitAndClick(e.raiseHandBtn);
await this.dashboardPage.reloadPage();
// User Name check
const userNameCheck = await rowFilter(this.dashboardPage, 'tr', /Moderator/, e.userNameDashboard);
await expect(userNameCheck).toHaveText(/Moderator/, { timeout: ELEMENT_WAIT_EXTRA_LONG_TIME });
// Webcam Time check
const webcamCheck = await rowFilter(this.dashboardPage, 'tr', /Moderator/, e.userWebcamTimeDashboard);
await expect(webcamCheck).toHaveText(/00/, { timeout: ELEMENT_WAIT_EXTRA_LONG_TIME });
// Raise Hand check
const raiseHandCheck = await rowFilter(this.dashboardPage, 'tr', /Moderator/, e.userRaiseHandDashboard);
await expect(raiseHandCheck).toHaveText(/1/, { timeout: ELEMENT_WAIT_EXTRA_LONG_TIME });
// Current Status check
const userStatusCheck = await rowFilter(this.dashboardPage, 'tr', /Moderator/, e.userStatusDashboard);
await expect(userStatusCheck).toHaveText(/Online/, { timeout: ELEMENT_WAIT_EXTRA_LONG_TIME });
}
async downloadSessionLearningDashboard(testInfo) {
await this.modPage.waitAndClick(e.optionsButton);
await this.modPage.waitAndClick(e.logout);
await this.modPage.waitAndClick('button');
const downloadSessionLocator = this.dashboardPage.getLocator(e.downloadSessionLearningDashboard);
const dataCSV = await this.dashboardPage.handleDownload(downloadSessionLocator, testInfo);
const dataToCheck = [
'Moderator',
'Activity Score',
'Talk time',
'Webcam Time',
'Messages',
'Emojis',
'Poll Votes',
'Raise Hands',
'Left',
'Join',
'Duration',
]
await checkTextContent(dataCSV.content, dataToCheck);
}
}
exports.LearningDashboard = LearningDashboard;

View File

@ -15,12 +15,24 @@ test.describe.serial('Learning Dashboard', async () => {
await learningDashboard.writeOnPublicChat();
});
test('Meeting Duration Time', async() => {
test('User Time On Meeting', async() => {
await learningDashboard.userTimeOnMeeting();
});
test('Polls', async ({ context })=> {
test('Polls', async ({ context }) => {
await learningDashboard.initUserPage(true, context);
await learningDashboard.polls();
});
test('Basic Infos', async () => {
await learningDashboard.basicInfos();
});
test('Overview', async () => {
await learningDashboard.overview();
});
test('Download Session Learning Dashboard', async ({ context }, testInfo) => {
await learningDashboard.downloadSessionLearningDashboard(testInfo);
});
});

View File

@ -15,5 +15,11 @@ function timeInSeconds(locator){
return hours * 3600 + minutes * 60 + seconds;
}
async function rowFilter(testPage, locator, role, selector) {
const locatorToFilter = await testPage.getLocator(locator);
return locatorToFilter.filter({ hasText: role }).locator(selector);
}
exports.openPoll = openPoll;
exports.timeInSeconds = timeInSeconds;
exports.rowFilter = rowFilter;

View File

@ -135,8 +135,8 @@ class Options extends Page {
// Decreasing font size
await openSettings(this);
await this.waitAndClick(e.descreaseFontSize);
await this.waitAndClick(e.descreaseFontSize);
await this.waitAndClick(e.decreaseFontSize);
await this.waitAndClick(e.decreaseFontSize);
await this.waitAndClick(e.modalConfirmButton);
await this.fontSizeCheck(e.chatButton, '12px');

View File

@ -1,4 +1,5 @@
require('dotenv').config();
const { chromiumConfig, firefoxConfig, webkitConfig } = require('./core/browsersConfig');
const CI = process.env.CI === 'true';
const DEBUG_MODE = process.env.DEBUG_MODE === 'true';
@ -20,45 +21,9 @@ const config = {
video: 'on',
},
projects: [
{
name: 'Chromium',
use: {
browserName: 'chromium',
launchOptions: {
args: [
'--no-sandbox',
'--ignore-certificate-errors',
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
]
},
},
},
{
name: 'Firefox',
use: {
browserName: 'firefox',
launchOptions: {
firefoxUserPrefs: {
"media.navigator.streams.fake": true,
"media.navigator.permission.disabled": true,
}
},
},
},
{
name: 'WebKit',
use: {
browserName: 'webkit',
launchOptions: {
args: [
'--no-sandbox',
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
]
},
},
},
chromiumConfig,
firefoxConfig,
webkitConfig,
],
};

View File

@ -49,6 +49,12 @@ class SharedNotes extends MultiUsers {
await this.editMessage(notesLocator);
const editedMessage = '!Hello';
await expect(notesLocator).toContainText(editedMessage, { timeout: ELEMENT_WAIT_TIME });
const wbBox = await this.modPage.getElementBoundingBox(e.etherpadFrame);
await expect(this.modPage.page).toHaveScreenshot('sharednotes-1.png', {
maxDiffPixels: 10,
clip: wbBox,
});
}
async formatMessage() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -4,11 +4,11 @@ const Page = require('../core/page');
const e = require('../core/elements');
const { waitAndClearDefaultPresentationNotification } = require('../notifications/util');
const { sleep } = require('../core/helpers');
const { checkTextContent, checkElementLengthEqualTo } = require('../core/util');
const { checkAvatarIcon, checkIsPresenter, checkMutedUsers } = require('./util');
const { getNotesLocator } = require('../sharednotes/util');
const { checkTextContent } = require('../core/util');
const { getSettings } = require('../core/settings');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { ELEMENT_WAIT_TIME, ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
class MultiUsers {
constructor(browser, context) {
@ -19,7 +19,7 @@ class MultiUsers {
async initPages(page1, waitAndClearDefaultPresentationNotificationModPage = false) {
await this.initModPage(page1);
if (waitAndClearDefaultPresentationNotificationModPage) {
await waitAndClearDefaultPresentationNotification(this.modPage);
await waitAndClearDefaultPresentationNotification(this.modPage);
}
await this.initUserPage();
}
@ -233,6 +233,32 @@ class MultiUsers {
await this.userPage2.wasRemoved(e.selectedUserName);
}
async pinningWebcams() {
await this.modPage.shareWebcam();
await this.modPage2.shareWebcam();
await this.userPage.shareWebcam();
await this.modPage.page.waitForFunction(
checkElementLengthEqualTo,
[e.webcamVideoItem, 3],
{ timeout: ELEMENT_WAIT_TIME },
);
// Pin first webcam (Mod2)
await this.modPage.waitAndClick(`:nth-match(${e.dropdownWebcamButton}, 3)`);
await this.modPage.waitAndClick(`:nth-match(${e.pinWebcamBtn}, 2)`);
await this.modPage.hasText(`:nth-match(${e.dropdownWebcamButton}, 1)`, this.modPage2.username);
await this.modPage2.hasText(`:nth-match(${e.dropdownWebcamButton}, 1)`, this.modPage2.username);
await this.userPage.hasText(`:nth-match(${e.dropdownWebcamButton}, 1)`, this.modPage2.username);
// Pin second webcam (user)
await this.modPage.waitAndClick(`:nth-match(${e.dropdownWebcamButton}, 3)`);
await this.modPage.waitAndClick(`:nth-match(${e.pinWebcamBtn}, 3)`);
await this.modPage.hasText(`:nth-match(${e.dropdownWebcamButton}, 1)`, this.userPage.username);
await this.modPage.hasText(`:nth-match(${e.dropdownWebcamButton}, 2)`, this.modPage2.username);
await this.userPage.hasText(`:nth-match(${e.dropdownWebcamButton}, 1)`, this.modPage2.username);
await this.userPage.hasText(`:nth-match(${e.dropdownWebcamButton}, 2)`, this.userPage.username);
await this.modPage2.hasText(`:nth-match(${e.dropdownWebcamButton}, 1)`, this.userPage.username);
await this.modPage2.hasText(`:nth-match(${e.dropdownWebcamButton}, 2)`, this.modPage2.username);
}
async whiteboardAccess() {
await this.modPage.waitForSelector(e.whiteboard);
await this.modPage.waitAndClick(e.userListItem);

View File

@ -1,6 +1,8 @@
const e = require('../core/elements');
const { sleep } = require('../core/helpers');
const { LOOP_INTERVAL, ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { LOOP_INTERVAL, ELEMENT_WAIT_LONGER_TIME, ELEMENT_WAIT_TIME } = require('../core/constants');
const { expect } = require('@playwright/test');
const { resolve } = require('path');
// loop 5 times, every LOOP_INTERVAL milliseconds, and check that all
// videos displayed are changing by comparing a hash of their
@ -43,4 +45,26 @@ async function webcamContentCheck(test) {
return check === true;
}
async function checkVideoUploadData(testPage, previousValue, timeout = ELEMENT_WAIT_TIME) {
const locator = testPage.getLocator(e.videoUploadRateData);
await expect(locator).not.toHaveText('0k ↑', { timeout });
const currentValue = await Number((await locator.textContent()).split('k')[0]);
await expect(currentValue).toBeGreaterThan(previousValue);
return currentValue;
}
async function uploadBackgroundVideoImage(testPage) {
const [fileChooser] = await Promise.all([
testPage.page.waitForEvent('filechooser'),
testPage.waitAndClick(e.inputBackgroundButton),
]);
await fileChooser.setFiles(resolve(__dirname, '../core/media/simpsons-background.png'));
const uploadedBackgroundLocator = testPage.getLocator(e.selectCustomBackground);
await expect(uploadedBackgroundLocator).toHaveScreenshot('custom-background-item.png', {
maxDiffPixelRatio: 0.1,
});
}
exports.webcamContentCheck = webcamContentCheck;
exports.checkVideoUploadData = checkVideoUploadData;
exports.uploadBackgroundVideoImage = uploadBackgroundVideoImage;

View File

@ -1,8 +1,9 @@
const { expect } = require('@playwright/test');
const Page = require('../core/page');
const e = require('../core/elements');
const { webcamContentCheck } = require('./util');
const { VIDEO_LOADING_WAIT_TIME } = require('../core/constants');
const { checkVideoUploadData, uploadBackgroundVideoImage, webcamContentCheck } = require('./util');
const { VIDEO_LOADING_WAIT_TIME, ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { sleep } = require('../core/helpers');
class Webcam extends Page {
constructor(browser, page) {
@ -13,11 +14,17 @@ class Webcam extends Page {
const { videoPreviewTimeout, skipVideoPreview, skipVideoPreviewOnFirstJoin } = this.settings;
await this.shareWebcam(!(skipVideoPreview || skipVideoPreviewOnFirstJoin), videoPreviewTimeout);
await this.hasElement('video');
await this.hasElement(e.videoDropdownMenu);
await this.waitAndClick(e.leaveVideo);
await this.hasElement(e.joinVideo);
await this.wasRemoved('video');
}
async checksContent() {
const { videoPreviewTimeout, skipVideoPreview, skipVideoPreviewOnFirstJoin } = this.settings;
await this.shareWebcam(!(skipVideoPreview || skipVideoPreviewOnFirstJoin), videoPreviewTimeout);
await this.waitForSelector(e.webcamVideoItem);
await this.wasRemoved(e.webcamConnecting, ELEMENT_WAIT_LONGER_TIME);
const respUser = await webcamContentCheck(this);
await expect(respUser).toBeTruthy();
}
@ -30,6 +37,94 @@ class Webcam extends Page {
await this.hasElement(e.webcamItemTalkingUser);
}
async changeVideoQuality() {
const { videoPreviewTimeout } = this.settings;
const joinWebcamSettingQuality = async (value) => {
await this.waitAndClick(e.joinVideo);
await this.waitForSelector(e.videoQualitySelector);
const langDropdown = await this.page.$(e.videoQualitySelector);
await langDropdown.selectOption({ value });
await this.waitForSelector(e.videoPreview, videoPreviewTimeout);
await this.waitAndClick(e.startSharingWebcam);
await this.waitForSelector(e.webcamConnecting);
await this.waitForSelector(e.leaveVideo, VIDEO_LOADING_WAIT_TIME);
}
await joinWebcamSettingQuality('low');
await this.waitAndClick(e.connectionStatusBtn);
const lowValue = await checkVideoUploadData(this, 0);
await this.waitAndClick(e.closeModal);
await this.waitAndClick(e.leaveVideo);
await joinWebcamSettingQuality('high');
await this.waitAndClick(e.connectionStatusBtn);
await checkVideoUploadData(this, lowValue);
}
async applyBackground() {
await this.waitAndClick(e.joinVideo);
await this.waitForSelector(e.noneBackgroundButton);
await this.waitAndClick(`${e.selectDefaultBackground}[aria-label="Home"]`);
await sleep(1000);
await this.waitAndClick(e.startSharingWebcam);
await this.waitForSelector(e.webcamContainer);
const webcamVideoLocator = await this.getLocator(e.webcamContainer);
await expect(webcamVideoLocator).toHaveScreenshot('webcam-with-home-background.png', {
maxDiffPixelRatio: 0.1,
});
}
async webcamFullscreen() {
await this.shareWebcam();
// get default viewport sizes
const { windowWidth, windowHeight } = await this.page.evaluate(() => { return {
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
}});
await this.waitAndClick(e.webcamFullscreenButton);
// get fullscreen webcam size
const { width, height } = await this.getLocator('video').boundingBox();
await expect(width + 1).toBe(windowWidth); // not sure why there is a difference of 1 pixel
await expect(height).toBe(windowHeight);
}
async managingNewBackground() {
await this.waitAndClick(e.joinVideo);
await this.waitForSelector(e.noneBackgroundButton);
// Upload
await uploadBackgroundVideoImage(this);
// Apply
await this.waitAndClick(e.selectCustomBackground);
await sleep(1000);
await this.waitAndClick(e.startSharingWebcam);
await this.waitForSelector(e.webcamContainer);
const webcamVideoLocator = await this.getLocator(e.webcamContainer);
await expect(webcamVideoLocator).toHaveScreenshot('webcam-with-new-background.png', {
maxDiffPixelRatio: 0.1,
});
// Remove
await this.waitAndClick(e.videoDropdownMenu);
await this.waitAndClick(e.advancedVideoSettingsBtn);
await this.waitAndClick(e.removeCustomBackground);
await this.wasRemoved(e.selectCustomBackground);
}
async keepBackgroundWhenRejoin(context) {
await this.waitAndClick(e.joinVideo);
await this.waitForSelector(e.noneBackgroundButton);
await uploadBackgroundVideoImage(this);
// Create a new page before closing the previous to not close the current context
await context.newPage();
await this.page.close();
const openedPage = await this.getLastTargetPage(context);
await openedPage.init(true, true, { meetingId: this.meetingId });
await openedPage.waitAndClick(e.joinVideo);
await openedPage.hasElement(e.selectCustomBackground);
}
async webcamLayoutStart() {
await this.joinMicrophone();
const { videoPreviewTimeout, skipVideoPreview, skipVideoPreviewOnFirstJoin } = this.settings;

View File

@ -1,4 +1,5 @@
const { test } = require('@playwright/test');
const { MultiUsers } = require('../user/multiusers');
const { Webcam } = require('./webcam');
test.describe.parallel('Webcam @ci', () => {
@ -10,15 +11,54 @@ test.describe.parallel('Webcam @ci', () => {
});
test('Checks content of webcam', async ({ browser, page }) => {
test.fixme(true, 'The test is not as reliable as it should be: getting unexpected failures');
const webcam = new Webcam(browser, page);
await webcam.init(true, true);
await webcam.checksContent();
});
test('Checks webcam talking indicator', async ({ browser, page }) => {
test('Webcam talking indicator', async ({ browser, page }) => {
const webcam = new Webcam(browser, page);
await webcam.init(true, false);
await webcam.talkingIndicator();
});
test('Pinning and unpinning webcams', async ({ browser, context, page }) => {
const webcam = new MultiUsers(browser, context);
await webcam.initModPage(page);
await webcam.initUserPage();
await webcam.initModPage2();
await webcam.pinningWebcams();
});
test('Change video quality', async ({ browser, page }) => {
const webcam = new Webcam(browser, page);
await webcam.init(true, true);
await webcam.changeVideoQuality();
});
test('Webcam fullscreen', async ({ browser, page }) => {
const webcam = new Webcam(browser, page);
await webcam.init(true, true);
await webcam.webcamFullscreen();
});
test.describe('Webcam background', () => {
test('Select one of the default backgrounds', async ({ browser, page }) => {
const webcam = new Webcam(browser, page);
await webcam.init(true, true);
await webcam.applyBackground();
});
test('Managing new background', async ({ browser, page }) => {
const webcam = new Webcam(browser, page);
await webcam.init(true, true);
await webcam.managingNewBackground();
});
test('Keep background when rejoin', async ({ browser, context, page }) => {
const webcam = new Webcam(browser, page);
await webcam.init(true, true);
await webcam.keepBackgroundWhenRejoin(context);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -148,4 +148,4 @@ class Create {
}
}
module.exports = exports = Create;
module.exports = exports = Create;

View File

@ -154,4 +154,4 @@ const chatTest = () => {
});
};
module.exports = exports = chatTest;
module.exports = exports = chatTest;

View File

@ -40,4 +40,4 @@ class Clear extends Page {
}
}
module.exports = exports = Clear;
module.exports = exports = Clear;

View File

@ -247,4 +247,4 @@ exports.pencil = 'button[aria-label="Pencil"]';
exports.rectangle = 'button[aria-label="Rectangle"]';
exports.drawnRectangle = 'svg g[clip-path] > g:nth-child(2) rect[data-test="drawnRectangle"]';
exports.changeWhiteboardAccess = 'li[data-test="changeWhiteboardAccess"]';
exports.whiteboardViewBox = 'svg g[clip-path="url(#viewBox)"]';
exports.whiteboardViewBox = 'svg g[clip-path="url(#viewBox)"]';

View File

@ -460,4 +460,4 @@ class Page {
}
}
module.exports = exports = Page;
module.exports = exports = Page;

View File

@ -43,4 +43,4 @@ exports.checkElementTextIncludes = checkElementTextIncludes;
exports.getElementText = getElementText;
exports.checkElementLengthEqualTo = checkElementLengthEqualTo;
exports.checkElementLengthDifferentTo = checkElementLengthDifferentTo;
exports.getElementLength = getElementLength;
exports.getElementLength = getElementLength;

View File

@ -758,4 +758,4 @@ class CustomParameters {
}
}
module.exports = exports = CustomParameters;
module.exports = exports = CustomParameters;

View File

@ -690,4 +690,4 @@ const customParametersTest = () => {
});
};
module.exports = exports = customParametersTest;
module.exports = exports = customParametersTest;

View File

@ -200,4 +200,4 @@ class Notifications extends MultiUsers {
}
}
module.exports = exports = Notifications;
module.exports = exports = Notifications;

View File

@ -219,4 +219,4 @@ const pollingTest = () => {
}, TEST_DURATION_TIME);
};
module.exports = exports = pollingTest;
module.exports = exports = pollingTest;

View File

@ -16,4 +16,4 @@ async function startPoll(test, shouldPublishPoll = false, isAnonymous = false) {
}
exports.openPoll = openPoll;
exports.startPoll = startPoll;
exports.startPoll = startPoll;

View File

@ -184,4 +184,4 @@ class Presentation {
}
}
module.exports = exports = Presentation;
module.exports = exports = Presentation;

View File

@ -38,4 +38,4 @@ async function uploadPresentation(test, fileName, uploadTimeout = ELEMENT_WAIT_L
exports.checkSvgIndex = checkSvgIndex;
exports.getSvgOuterHtml = getSvgOuterHtml;
exports.uploadPresentation = uploadPresentation;
exports.uploadPresentation = uploadPresentation;

View File

@ -32,4 +32,4 @@ class ShareScreen extends Page {
}
}
module.exports = exports = ShareScreen;
module.exports = exports = ShareScreen;

View File

@ -98,4 +98,4 @@ const screenShareTest = () => {
Page.checkRegression(1.37, screenshot);
});
};
module.exports = exports = screenShareTest;
module.exports = exports = screenShareTest;

View File

@ -13,4 +13,4 @@ async function getScreenShareBreakoutContainer(test) {
}
exports.getScreenShareBreakoutContainer = getScreenShareBreakoutContainer;
exports.startScreenshare = startScreenshare;
exports.startScreenshare = startScreenshare;

View File

@ -36,4 +36,4 @@ const sharedNotesTest = () => {
});
};
module.exports = exports = sharedNotesTest;
module.exports = exports = sharedNotesTest;

View File

@ -107,4 +107,4 @@ class Stress {
}
}
module.exports = exports = Stress;
module.exports = exports = Stress;

Some files were not shown because too many files have changed in this diff Show More