Merge branch 'v2.6.x-release' into presentation-test-impr
This commit is contained in:
commit
659c4030dd
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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};
|
||||
|
@ -98,7 +98,7 @@ const FullscreenButtonComponent = ({
|
||||
label={formattedLabel(isFullscreen)}
|
||||
hideLabel
|
||||
isStyled={fullScreenStyle}
|
||||
data-test="presentationFullscreenButton"
|
||||
data-test="webcamFullscreenButton"
|
||||
/>
|
||||
</Styled.FullscreenButtonWrapper>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -9,3 +9,9 @@
|
||||
fill: #333333;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tippy-tooltip.bbbtip-theme>.tippy-content{
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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)}>
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
|
@ -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_');
|
||||
|
@ -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 })}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,6 +25,7 @@ export default withTracker(({ swapLayout, ...rest }) => {
|
||||
totalNumberOfStreams,
|
||||
isUserLocked: VideoService.isUserLocked(),
|
||||
currentVideoPageIndex: VideoService.getCurrentVideoPageIndex(),
|
||||
isMeteorConnected: Meteor.status().connected,
|
||||
...rest,
|
||||
};
|
||||
})(VideoProviderContainer);
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -101,6 +101,9 @@ class PanToolInjector extends React.Component {
|
||||
}
|
||||
}}
|
||||
hideLabel
|
||||
{...{
|
||||
panSelected,
|
||||
}}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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.",
|
||||
|
@ -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": "Δεν έχει ληφθεί το αναγνωριστικό συνεδρίας.",
|
||||
|
@ -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.",
|
||||
|
@ -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.",
|
||||
|
@ -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.",
|
||||
|
@ -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.",
|
||||
|
@ -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.",
|
||||
|
@ -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": "هیچ توکن جلسهای دریافت نشد.",
|
||||
|
@ -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.",
|
||||
|
@ -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.",
|
||||
|
@ -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.",
|
||||
|
@ -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": "Սեանսի թոքեն չի ստացվել",
|
||||
|
@ -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": "会議の参加許可が得られていません。",
|
||||
|
@ -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": "Токен вебинара не получен.",
|
||||
|
@ -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
3
bigbluebutton-tests/gns3/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
NPDC
|
||||
bbb-dev-ca.crt
|
||||
bbb-dev-ca.key
|
1
bigbluebutton-tests/gns3/NPDC.placeholder.sh
Executable file
1
bigbluebutton-tests/gns3/NPDC.placeholder.sh
Executable file
@ -0,0 +1 @@
|
||||
git clone --branch master --depth 1 https://github.com/BrentBaccala/NPDC NPDC
|
@ -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`
|
||||
|
@ -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
|
||||
|
||||
|
50
bigbluebutton-tests/playwright/core/browsersConfig.js
Normal file
50
bigbluebutton-tests/playwright/core/browsersConfig.js
Normal 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;
|
@ -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 |
@ -5,4 +5,4 @@ module.exports = exports = {
|
||||
fullName: 'User1',
|
||||
moderatorPW: 'mp',
|
||||
attendeePW: 'ap',
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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');
|
||||
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
@ -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 |
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 |
@ -148,4 +148,4 @@ class Create {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = Create;
|
||||
module.exports = exports = Create;
|
||||
|
@ -154,4 +154,4 @@ const chatTest = () => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = exports = chatTest;
|
||||
module.exports = exports = chatTest;
|
||||
|
@ -40,4 +40,4 @@ class Clear extends Page {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = Clear;
|
||||
module.exports = exports = Clear;
|
||||
|
@ -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)"]';
|
||||
|
@ -460,4 +460,4 @@ class Page {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = Page;
|
||||
module.exports = exports = Page;
|
||||
|
@ -43,4 +43,4 @@ exports.checkElementTextIncludes = checkElementTextIncludes;
|
||||
exports.getElementText = getElementText;
|
||||
exports.checkElementLengthEqualTo = checkElementLengthEqualTo;
|
||||
exports.checkElementLengthDifferentTo = checkElementLengthDifferentTo;
|
||||
exports.getElementLength = getElementLength;
|
||||
exports.getElementLength = getElementLength;
|
||||
|
@ -758,4 +758,4 @@ class CustomParameters {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = CustomParameters;
|
||||
module.exports = exports = CustomParameters;
|
||||
|
@ -690,4 +690,4 @@ const customParametersTest = () => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = exports = customParametersTest;
|
||||
module.exports = exports = customParametersTest;
|
||||
|
@ -200,4 +200,4 @@ class Notifications extends MultiUsers {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = Notifications;
|
||||
module.exports = exports = Notifications;
|
||||
|
@ -219,4 +219,4 @@ const pollingTest = () => {
|
||||
}, TEST_DURATION_TIME);
|
||||
};
|
||||
|
||||
module.exports = exports = pollingTest;
|
||||
module.exports = exports = pollingTest;
|
||||
|
@ -16,4 +16,4 @@ async function startPoll(test, shouldPublishPoll = false, isAnonymous = false) {
|
||||
}
|
||||
|
||||
exports.openPoll = openPoll;
|
||||
exports.startPoll = startPoll;
|
||||
exports.startPoll = startPoll;
|
||||
|
@ -184,4 +184,4 @@ class Presentation {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = Presentation;
|
||||
module.exports = exports = Presentation;
|
||||
|
@ -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;
|
||||
|
@ -32,4 +32,4 @@ class ShareScreen extends Page {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = ShareScreen;
|
||||
module.exports = exports = ShareScreen;
|
||||
|
@ -98,4 +98,4 @@ const screenShareTest = () => {
|
||||
Page.checkRegression(1.37, screenshot);
|
||||
});
|
||||
};
|
||||
module.exports = exports = screenShareTest;
|
||||
module.exports = exports = screenShareTest;
|
||||
|
@ -13,4 +13,4 @@ async function getScreenShareBreakoutContainer(test) {
|
||||
}
|
||||
|
||||
exports.getScreenShareBreakoutContainer = getScreenShareBreakoutContainer;
|
||||
exports.startScreenshare = startScreenshare;
|
||||
exports.startScreenshare = startScreenshare;
|
||||
|
@ -36,4 +36,4 @@ const sharedNotesTest = () => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = exports = sharedNotesTest;
|
||||
module.exports = exports = sharedNotesTest;
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user