1100 lines
34 KiB
JavaScript
Executable File
1100 lines
34 KiB
JavaScript
Executable File
import { Tracker } from 'meteor/tracker';
|
|
import { Session } from 'meteor/session';
|
|
import Settings from '/imports/ui/services/settings';
|
|
import Auth from '/imports/ui/services/auth';
|
|
import Meetings from '/imports/api/meetings';
|
|
import Users from '/imports/api/users';
|
|
import VideoStreams from '/imports/api/video-streams';
|
|
import UserListService from '/imports/ui/components/user-list/service';
|
|
import { meetingIsBreakout } from '/imports/ui/components/app/service';
|
|
import { makeCall } from '/imports/ui/services/api';
|
|
import { notify } from '/imports/ui/services/notification';
|
|
import deviceInfo from '/imports/utils/deviceInfo';
|
|
import browserInfo from '/imports/utils/browserInfo';
|
|
import getFromUserSettings from '/imports/ui/services/users-settings';
|
|
import VideoPreviewService from '../video-preview/service';
|
|
import Storage from '/imports/ui/services/storage/session';
|
|
import BBBStorage from '/imports/ui/services/storage';
|
|
import logger from '/imports/startup/client/logger';
|
|
import { debounce } from '/imports/utils/debounce';
|
|
import { partition } from '/imports/utils/array-utils';
|
|
import {
|
|
getSortingMethod,
|
|
sortVideoStreams,
|
|
} from '/imports/ui/components/video-provider/stream-sorting';
|
|
import getFromMeetingSettings from '/imports/ui/services/meeting-settings';
|
|
|
|
const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles;
|
|
const MULTIPLE_CAMERAS = Meteor.settings.public.app.enableMultipleCameras;
|
|
|
|
const SFU_URL = Meteor.settings.public.kurento.wsUrl;
|
|
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
|
|
const ROLE_VIEWER = Meteor.settings.public.user.role_viewer;
|
|
const MIRROR_WEBCAM = Meteor.settings.public.app.mirrorOwnWebcam;
|
|
const PIN_WEBCAM = Meteor.settings.public.kurento.enableVideoPin;
|
|
const {
|
|
thresholds: CAMERA_QUALITY_THRESHOLDS = [],
|
|
applyConstraints: CAMERA_QUALITY_THR_CONSTRAINTS = false,
|
|
debounceTime: CAMERA_QUALITY_THR_DEBOUNCE = 2500,
|
|
} = Meteor.settings.public.kurento.cameraQualityThresholds;
|
|
const {
|
|
paginationToggleEnabled: PAGINATION_TOGGLE_ENABLED,
|
|
pageChangeDebounceTime: PAGE_CHANGE_DEBOUNCE_TIME,
|
|
desktopPageSizes: DESKTOP_PAGE_SIZES,
|
|
mobilePageSizes: MOBILE_PAGE_SIZES,
|
|
desktopGridSizes: DESKTOP_GRID_SIZES,
|
|
mobileGridSizes: MOBILE_GRID_SIZES,
|
|
} = Meteor.settings.public.kurento.pagination;
|
|
const PAGINATION_THRESHOLDS_CONF = Meteor.settings.public.kurento.paginationThresholds;
|
|
const PAGINATION_THRESHOLDS = PAGINATION_THRESHOLDS_CONF.thresholds.sort((t1, t2) => t1.users - t2.users);
|
|
const PAGINATION_THRESHOLDS_ENABLED = PAGINATION_THRESHOLDS_CONF.enabled;
|
|
const {
|
|
paginationSorting: PAGINATION_SORTING,
|
|
defaultSorting: DEFAULT_SORTING,
|
|
} = Meteor.settings.public.kurento.cameraSortingModes;
|
|
const DEFAULT_VIDEO_MEDIA_SERVER = Meteor.settings.public.kurento.videoMediaServer;
|
|
|
|
const FILTER_VIDEO_STATS = [
|
|
'outbound-rtp',
|
|
'inbound-rtp',
|
|
];
|
|
|
|
const TOKEN = '_';
|
|
|
|
class VideoService {
|
|
constructor() {
|
|
this.defineProperties({
|
|
isConnecting: false,
|
|
isConnected: false,
|
|
currentVideoPageIndex: 0,
|
|
numberOfPages: 0,
|
|
pageSize: 0,
|
|
});
|
|
this.userParameterProfile = null;
|
|
|
|
this.isMobile = deviceInfo.isMobile;
|
|
this.isSafari = browserInfo.isSafari;
|
|
this.numberOfDevices = 0;
|
|
|
|
this.record = null;
|
|
this.hackRecordViewer = null;
|
|
|
|
// If the page isn't served over HTTPS there won't be mediaDevices
|
|
if (navigator.mediaDevices) {
|
|
this.updateNumberOfDevices = this.updateNumberOfDevices.bind(this);
|
|
// Safari doesn't support ondevicechange
|
|
if (!this.isSafari) {
|
|
navigator.mediaDevices.ondevicechange = event => this.updateNumberOfDevices();
|
|
}
|
|
this.updateNumberOfDevices();
|
|
}
|
|
|
|
// FIXME this is abhorrent. Remove when peer lifecycle is properly decoupled
|
|
// from the React component's lifecycle. Any attempt at a half-baked
|
|
// decoupling will most probably generate problems - prlanzarin Dec 16 2021
|
|
this.webRtcPeersRef = {};
|
|
}
|
|
|
|
defineProperties(obj) {
|
|
Object.keys(obj).forEach((key) => {
|
|
const privateKey = `_${key}`;
|
|
this[privateKey] = {
|
|
value: obj[key],
|
|
tracker: new Tracker.Dependency(),
|
|
};
|
|
|
|
Object.defineProperty(this, key, {
|
|
set: (value) => {
|
|
this[privateKey].value = value;
|
|
this[privateKey].tracker.changed();
|
|
},
|
|
get: () => {
|
|
this[privateKey].tracker.depend();
|
|
return this[privateKey].value;
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
fetchNumberOfDevices(devices) {
|
|
const deviceIds = [];
|
|
devices.forEach(d => {
|
|
const validDeviceId = d.deviceId !== '' && !deviceIds.includes(d.deviceId)
|
|
if (d.kind === 'videoinput' && validDeviceId) {
|
|
deviceIds.push(d.deviceId);
|
|
}
|
|
});
|
|
|
|
return deviceIds.length;
|
|
}
|
|
|
|
updateNumberOfDevices(devices = null) {
|
|
if (devices) {
|
|
this.numberOfDevices = this.fetchNumberOfDevices(devices);
|
|
} else {
|
|
navigator.mediaDevices.enumerateDevices().then(devices => {
|
|
this.numberOfDevices = this.fetchNumberOfDevices(devices);
|
|
});
|
|
}
|
|
}
|
|
|
|
joinVideo(deviceId) {
|
|
this.deviceId = deviceId;
|
|
this.isConnecting = true;
|
|
Storage.setItem('isFirstJoin', false);
|
|
}
|
|
|
|
joinedVideo() {
|
|
this.isConnected = true;
|
|
}
|
|
|
|
storeDeviceIds() {
|
|
const streams = VideoStreams.find(
|
|
{
|
|
meetingId: Auth.meetingID,
|
|
userId: Auth.userID,
|
|
}, { fields: { deviceId: 1 } },
|
|
).fetch();
|
|
|
|
let deviceIds = [];
|
|
streams.forEach(s => {
|
|
deviceIds.push(s.deviceId);
|
|
}
|
|
);
|
|
Session.set('deviceIds', deviceIds.join());
|
|
}
|
|
|
|
exitVideo() {
|
|
if (this.isConnected) {
|
|
logger.info({
|
|
logCode: 'video_provider_unsharewebcam',
|
|
}, `Sending unshare all ${Auth.userID} webcams notification to meteor`);
|
|
const streams = VideoStreams.find(
|
|
{
|
|
meetingId: Auth.meetingID,
|
|
userId: Auth.userID,
|
|
}, { fields: { stream: 1 } },
|
|
).fetch();
|
|
|
|
streams.forEach(s => this.sendUserUnshareWebcam(s.stream));
|
|
this.exitedVideo();
|
|
}
|
|
}
|
|
|
|
exitedVideo() {
|
|
this.isConnecting = false;
|
|
this.deviceId = null;
|
|
this.isConnected = false;
|
|
}
|
|
|
|
stopVideo(cameraId) {
|
|
const streams = VideoStreams.find(
|
|
{
|
|
meetingId: Auth.meetingID,
|
|
userId: Auth.userID,
|
|
}, { fields: { stream: 1 } },
|
|
).fetch();
|
|
|
|
const hasTargetStream = streams.some(s => s.stream === cameraId);
|
|
const hasOtherStream = streams.some(s => s.stream !== cameraId);
|
|
|
|
// Check if the target (cameraId) stream exists in the remote collection.
|
|
// If it does, means it was successfully shared. So do the full stop procedure.
|
|
if (hasTargetStream) {
|
|
this.sendUserUnshareWebcam(cameraId);
|
|
}
|
|
|
|
if (!hasOtherStream) {
|
|
// There's no other remote stream, meaning (OR)
|
|
// a) This was effectively the last webcam being unshared
|
|
// b) This was a connecting stream timing out (not effectively shared)
|
|
// For both cases, we clean everything up.
|
|
this.exitedVideo();
|
|
} else {
|
|
// It was not the last webcam the user had successfully shared,
|
|
// nor was cameraId present in the server collection.
|
|
// Hence it's a connecting stream (not effectively shared) which timed out
|
|
this.stopConnectingStream();
|
|
}
|
|
}
|
|
|
|
getSharedDevices() {
|
|
const devices = VideoStreams.find(
|
|
{
|
|
meetingId: Auth.meetingID,
|
|
userId: Auth.userID,
|
|
}, { fields: { deviceId: 1 } },
|
|
).fetch().map(vs => vs.deviceId);
|
|
|
|
return devices;
|
|
}
|
|
|
|
sendUserShareWebcam(cameraId) {
|
|
makeCall('userShareWebcam', cameraId);
|
|
}
|
|
|
|
sendUserUnshareWebcam(cameraId) {
|
|
makeCall('userUnshareWebcam', cameraId);
|
|
}
|
|
|
|
getAuthenticatedURL() {
|
|
return Auth.authenticateURL(SFU_URL);
|
|
}
|
|
|
|
shouldRenderPaginationToggle() {
|
|
// Only enable toggle if configured to do so and if we have a page size properly setup
|
|
return PAGINATION_TOGGLE_ENABLED && (this.getMyPageSize() > 0);
|
|
}
|
|
|
|
isPaginationEnabled () {
|
|
return Settings.application.paginationEnabled && (this.getMyPageSize() > 0);
|
|
}
|
|
|
|
setNumberOfPages (numberOfPublishers, numberOfSubscribers, pageSize) {
|
|
// Page size 0 means no pagination, return itself
|
|
if (pageSize === 0) return 0;
|
|
|
|
// Page size refers only to the number of subscribers. Publishers are always
|
|
// shown, hence not accounted for
|
|
const nofPages = Math.ceil(numberOfSubscribers / pageSize);
|
|
|
|
if (nofPages !== this.numberOfPages) {
|
|
this.numberOfPages = nofPages;
|
|
// Check if we have to page back on the current video page index due to a
|
|
// page ceasing to exist
|
|
if (nofPages === 0) {
|
|
this.currentVideoPageIndex = 0;
|
|
} else if ((this.currentVideoPageIndex + 1) > this.numberOfPages) {
|
|
this.getPreviousVideoPage();
|
|
}
|
|
}
|
|
|
|
return this.numberOfPages;
|
|
}
|
|
|
|
getNumberOfPages () {
|
|
return this.numberOfPages;
|
|
}
|
|
|
|
setCurrentVideoPageIndex (newVideoPageIndex) {
|
|
if (this.currentVideoPageIndex !== newVideoPageIndex) {
|
|
this.currentVideoPageIndex = newVideoPageIndex;
|
|
}
|
|
}
|
|
|
|
getCurrentVideoPageIndex () {
|
|
return this.currentVideoPageIndex;
|
|
}
|
|
|
|
calculateNextPage () {
|
|
if (this.numberOfPages === 0) {
|
|
return 0;
|
|
}
|
|
|
|
return ((this.currentVideoPageIndex + 1) % this.numberOfPages + this.numberOfPages) % this.numberOfPages;
|
|
}
|
|
|
|
calculatePreviousPage () {
|
|
if (this.numberOfPages === 0) {
|
|
return 0;
|
|
}
|
|
|
|
return ((this.currentVideoPageIndex - 1) % this.numberOfPages + this.numberOfPages) % this.numberOfPages;
|
|
}
|
|
|
|
getNextVideoPage() {
|
|
const nextPage = this.calculateNextPage();
|
|
this.setCurrentVideoPageIndex(nextPage);
|
|
|
|
return this.currentVideoPageIndex;
|
|
}
|
|
|
|
getPreviousVideoPage() {
|
|
const previousPage = this.calculatePreviousPage();
|
|
this.setCurrentVideoPageIndex(previousPage);
|
|
|
|
return this.currentVideoPageIndex;
|
|
}
|
|
|
|
getPageSizeDictionary () {
|
|
// Dynamic page sizes are disabled. Fetch the stock page sizes.
|
|
if (!PAGINATION_THRESHOLDS_ENABLED || PAGINATION_THRESHOLDS.length <= 0) {
|
|
return !this.isMobile ? DESKTOP_PAGE_SIZES : MOBILE_PAGE_SIZES;
|
|
}
|
|
|
|
// Dynamic page sizes are enabled. Get the user count, isolate the
|
|
// matching threshold entry, return the val.
|
|
let targetThreshold;
|
|
const userCount = UserListService.getUserCount();
|
|
const processThreshold = (threshold = {
|
|
desktopPageSizes: DESKTOP_PAGE_SIZES,
|
|
mobilePageSizes: MOBILE_PAGE_SIZES
|
|
}) => {
|
|
// We don't demand that all page sizes should be set in pagination profiles.
|
|
// That saves us some space because don't necessarily need to scale mobile
|
|
// endpoints.
|
|
// If eg mobile isn't set, then return the default value.
|
|
if (!this.isMobile) {
|
|
return threshold.desktopPageSizes || DESKTOP_PAGE_SIZES;
|
|
} else {
|
|
return threshold.mobilePageSizes || MOBILE_PAGE_SIZES;
|
|
}
|
|
};
|
|
|
|
// Short-circuit: no threshold yet, return stock values (processThreshold has a default arg)
|
|
if (userCount < PAGINATION_THRESHOLDS[0].users) return processThreshold();
|
|
|
|
// Reverse search for the threshold where our participant count is directly equal or great
|
|
// The PAGINATION_THRESHOLDS config is sorted when imported.
|
|
for (let mapIndex = PAGINATION_THRESHOLDS.length - 1; mapIndex >= 0; --mapIndex) {
|
|
targetThreshold = PAGINATION_THRESHOLDS[mapIndex];
|
|
if (targetThreshold.users <= userCount) {
|
|
return processThreshold(targetThreshold);
|
|
}
|
|
}
|
|
}
|
|
|
|
setPageSize (size) {
|
|
if (this.pageSize !== size) {
|
|
this.pageSize = size;
|
|
}
|
|
|
|
return this.pageSize;
|
|
}
|
|
|
|
getMyPageSize () {
|
|
let size;
|
|
const myRole = this.getMyRole();
|
|
const pageSizes = this.getPageSizeDictionary();
|
|
switch (myRole) {
|
|
case ROLE_MODERATOR:
|
|
size = pageSizes.moderator;
|
|
break;
|
|
case ROLE_VIEWER:
|
|
default:
|
|
size = pageSizes.viewer
|
|
}
|
|
|
|
return this.setPageSize(size);
|
|
}
|
|
|
|
getGridSize () {
|
|
let size;
|
|
const myRole = this.getMyRole();
|
|
const gridSizes = !this.isMobile ? DESKTOP_GRID_SIZES : MOBILE_GRID_SIZES;
|
|
|
|
switch (myRole) {
|
|
case ROLE_MODERATOR:
|
|
size = gridSizes.moderator;
|
|
break;
|
|
case ROLE_VIEWER:
|
|
default:
|
|
size = gridSizes.viewer
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
getVideoPage (streams, pageSize) {
|
|
// Publishers are taken into account for the page size calculations. They
|
|
// also appear on every page. Same for pinned user.
|
|
const [filtered, others] = partition(streams, (vs) => Auth.userID === vs.userId || vs.pin);
|
|
|
|
// Separate pin from local cameras
|
|
const [pin, mine] = partition(filtered, (vs) => vs.pin);
|
|
|
|
// Recalculate total number of pages
|
|
this.setNumberOfPages(filtered.length, others.length, pageSize);
|
|
const chunkIndex = this.currentVideoPageIndex * pageSize;
|
|
|
|
// This is an extra check because pagination is globally in effect (hard
|
|
// limited page sizes, toggles on), but we might still only have one page.
|
|
// Use the default sorting method if that's the case.
|
|
const sortingMethod = (this.numberOfPages > 1) ? PAGINATION_SORTING : DEFAULT_SORTING;
|
|
const paginatedStreams = sortVideoStreams(others, sortingMethod)
|
|
.slice(chunkIndex, (chunkIndex + pageSize)) || [];
|
|
|
|
if (getSortingMethod(sortingMethod).localFirst) {
|
|
return [...pin, ...mine, ...paginatedStreams];
|
|
}
|
|
return [...pin, ...paginatedStreams, ...mine];
|
|
}
|
|
|
|
getUsersIdFromVideoStreams() {
|
|
const usersId = VideoStreams.find(
|
|
{ meetingId: Auth.meetingID },
|
|
{ fields: { userId: 1 } },
|
|
).fetch().map(user => user.userId);
|
|
|
|
return usersId;
|
|
}
|
|
|
|
getVideoPinByUser(userId) {
|
|
const user = Users.findOne({ userId }, { fields: { pin: 1 } });
|
|
|
|
return user.pin;
|
|
}
|
|
|
|
toggleVideoPin(userId, userIsPinned) {
|
|
makeCall('changePin', userId, !userIsPinned);
|
|
}
|
|
|
|
isGridEnabled() {
|
|
return Session.get('isGridEnabled');
|
|
}
|
|
|
|
getVideoStreams() {
|
|
const pageSize = this.getMyPageSize();
|
|
const isPaginationDisabled = !this.isPaginationEnabled() || pageSize === 0;
|
|
const { neededDataTypes } = isPaginationDisabled
|
|
? getSortingMethod(DEFAULT_SORTING)
|
|
: getSortingMethod(PAGINATION_SORTING);
|
|
const isGridEnabled = this.isGridEnabled();
|
|
let gridUsers = [];
|
|
let users = [];
|
|
|
|
if (isGridEnabled) {
|
|
const selector = {
|
|
meetingId: Auth.meetingID,
|
|
};
|
|
|
|
if (this.hideUserlist() && this.getMyRole() === ROLE_VIEWER) {
|
|
selector.role = { $ne: ROLE_VIEWER };
|
|
}
|
|
|
|
users = Users.find(
|
|
selector,
|
|
{ fields: { loggedOut: 1, left: 1, ...neededDataTypes} },
|
|
).fetch();
|
|
}
|
|
|
|
let streams = VideoStreams.find(
|
|
{ meetingId: Auth.meetingID },
|
|
{ fields: neededDataTypes },
|
|
).fetch();
|
|
|
|
// Data savings enabled will only show local streams
|
|
const { viewParticipantsWebcams } = Settings.dataSaving;
|
|
if (!viewParticipantsWebcams) streams = this.filterLocalOnly(streams);
|
|
|
|
const moderatorOnly = this.webcamsOnlyForModerator();
|
|
if (moderatorOnly) streams = this.filterModeratorOnly(streams);
|
|
const connectingStream = this.getConnectingStream(streams);
|
|
if (connectingStream) streams.push(connectingStream);
|
|
|
|
// Pagination is either explictly disabled or pagination is set to 0 (which
|
|
// is equivalent to disabling it), so return the mapped streams as they are
|
|
// which produces the original non paginated behaviour
|
|
if (isPaginationDisabled) {
|
|
if (isGridEnabled) {
|
|
const streamUsers = streams.map((stream) => stream.userId);
|
|
|
|
gridUsers = users.filter(
|
|
(user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId)
|
|
).map((user) => ({
|
|
isGridItem: true,
|
|
...user,
|
|
}));
|
|
}
|
|
|
|
return {
|
|
streams: sortVideoStreams(streams, DEFAULT_SORTING),
|
|
gridUsers,
|
|
totalNumberOfStreams: streams.length
|
|
};
|
|
}
|
|
|
|
const paginatedStreams = this.getVideoPage(streams, pageSize);
|
|
|
|
if (isGridEnabled) {
|
|
const streamUsers = paginatedStreams.map((stream) => stream.userId);
|
|
|
|
gridUsers = users.filter(
|
|
(user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId)
|
|
).map((user) => ({
|
|
isGridItem: true,
|
|
...user,
|
|
}));
|
|
}
|
|
|
|
return { streams: paginatedStreams, gridUsers, totalNumberOfStreams: streams.length };
|
|
}
|
|
|
|
stopConnectingStream () {
|
|
this.deviceId = null;
|
|
this.isConnecting = false;
|
|
}
|
|
|
|
getConnectingStream(streams) {
|
|
let connectingStream;
|
|
|
|
if (this.isConnecting) {
|
|
if (this.deviceId) {
|
|
const stream = this.buildStreamName(Auth.userID, this.deviceId);
|
|
if (!this.hasStream(streams, stream) && !this.isUserLocked()) {
|
|
connectingStream = {
|
|
stream,
|
|
userId: Auth.userID,
|
|
name: Auth.fullname,
|
|
};
|
|
} else {
|
|
// Connecting stream is already stored at database
|
|
this.stopConnectingStream();
|
|
}
|
|
} else {
|
|
logger.error({
|
|
logCode: 'video_provider_missing_deviceid',
|
|
}, 'Could not retrieve a valid deviceId');
|
|
}
|
|
}
|
|
|
|
return connectingStream;
|
|
}
|
|
|
|
buildStreamName(userId, deviceId) {
|
|
return `${userId}${TOKEN}${deviceId}`;
|
|
}
|
|
|
|
hasVideoStream() {
|
|
const videoStreams = VideoStreams.findOne({ userId: Auth.userID },
|
|
{ fields: {} });
|
|
return !!videoStreams;
|
|
}
|
|
|
|
hasStream(streams, stream) {
|
|
return streams.find(s => s.stream === stream);
|
|
}
|
|
|
|
getMediaServerAdapter() {
|
|
return getFromMeetingSettings('media-server-video', DEFAULT_VIDEO_MEDIA_SERVER);
|
|
}
|
|
|
|
getMyRole () {
|
|
return Users.findOne({ userId: Auth.userID },
|
|
{ fields: { role: 1 } })?.role;
|
|
}
|
|
|
|
getRecord() {
|
|
if (this.record === null) {
|
|
this.record = getFromUserSettings('bbb_record_video', true);
|
|
}
|
|
|
|
// TODO: Remove this
|
|
// This is a hack to handle a missing piece at the backend of a particular deploy.
|
|
// If, at the time the video is shared, the user has a viewer role and
|
|
// meta_hack-record-viewer-video is 'false' this user won't have this video
|
|
// stream recorded.
|
|
if (this.hackRecordViewer === null) {
|
|
const value = getFromMeetingSettings('hack-record-viewer-video', null);
|
|
this.hackRecordViewer = value ? value.toLowerCase() === 'true' : true;
|
|
}
|
|
|
|
const hackRecord = this.getMyRole() === ROLE_MODERATOR || this.hackRecordViewer;
|
|
|
|
return this.record && hackRecord;
|
|
}
|
|
|
|
filterModeratorOnly(streams) {
|
|
const amIViewer = this.getMyRole() === ROLE_VIEWER;
|
|
|
|
if (amIViewer) {
|
|
const moderators = Users.find(
|
|
{
|
|
meetingId: Auth.meetingID,
|
|
role: ROLE_MODERATOR,
|
|
},
|
|
{ fields: { userId: 1 } },
|
|
).fetch().map(user => user.userId);
|
|
|
|
return streams.reduce((result, stream) => {
|
|
const { userId } = stream;
|
|
|
|
const isModerator = moderators.includes(userId);
|
|
const isMe = Auth.userID === userId;
|
|
|
|
if (isModerator || isMe) result.push(stream);
|
|
|
|
return result;
|
|
}, []);
|
|
}
|
|
return streams;
|
|
}
|
|
|
|
filterLocalOnly(streams) {
|
|
return streams.filter(stream => stream.userId === Auth.userID);
|
|
}
|
|
|
|
disableCam() {
|
|
const m = Meetings.findOne({ meetingId: Auth.meetingID },
|
|
{ fields: { 'lockSettingsProps.disableCam': 1 } });
|
|
return m.lockSettingsProps ? m.lockSettingsProps.disableCam : false;
|
|
}
|
|
|
|
webcamsOnlyForModerator() {
|
|
const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
|
|
{ fields: { 'usersProp.webcamsOnlyForModerator': 1 } });
|
|
const user = Users.findOne({ userId: Auth.userID }, { fields: { locked: 1, role: 1 } });
|
|
|
|
if (meeting?.usersProp && user?.role !== ROLE_MODERATOR && user?.locked) {
|
|
return meeting.usersProp.webcamsOnlyForModerator;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
hideUserlist() {
|
|
const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
|
|
{ fields: { 'lockSettingsProps.hideUserList': 1 } });
|
|
return meeting.lockSettingsProps ? meeting.lockSettingsProps.hideUserList : false;
|
|
}
|
|
|
|
hasCapReached() {
|
|
const meeting = Meetings.findOne(
|
|
{ meetingId: Auth.meetingID },
|
|
{
|
|
fields: {
|
|
'meetingProp.meetingCameraCap': 1,
|
|
'usersProp.userCameraCap': 1,
|
|
},
|
|
},
|
|
);
|
|
|
|
// If the meeting prop data is unreachable, force a safe return
|
|
if (!meeting?.usersProp || !meeting?.meetingProp) return true;
|
|
|
|
const { meetingCameraCap } = meeting.meetingProp;
|
|
const { userCameraCap } = meeting.usersProp;
|
|
|
|
const meetingCap = meetingCameraCap !== 0 && this.getVideoStreamsCount() >= meetingCameraCap;
|
|
const userCap = userCameraCap !== 0 && this.getLocalVideoStreamsCount() >= userCameraCap;
|
|
|
|
return meetingCap || userCap;
|
|
}
|
|
|
|
getVideoStreamsCount() {
|
|
const streams = VideoStreams.find({}).count();
|
|
|
|
return streams;
|
|
}
|
|
|
|
getLocalVideoStreamsCount() {
|
|
const localStreams = VideoStreams.find(
|
|
{ userId: Auth.userID }
|
|
).count();
|
|
|
|
return localStreams;
|
|
}
|
|
|
|
getInfo() {
|
|
const m = Meetings.findOne({ meetingId: Auth.meetingID },
|
|
{ fields: { 'voiceProp.voiceConf': 1 } });
|
|
const voiceBridge = m.voiceProp ? m.voiceProp.voiceConf : null;
|
|
return {
|
|
userId: Auth.userID,
|
|
userName: Auth.fullname,
|
|
meetingId: Auth.meetingID,
|
|
sessionToken: Auth.sessionToken,
|
|
voiceBridge,
|
|
};
|
|
}
|
|
|
|
mirrorOwnWebcam(userId = null) {
|
|
// only true if setting defined and video ids match
|
|
const isOwnWebcam = userId ? Auth.userID === userId : true;
|
|
const isEnabledMirroring = getFromUserSettings('bbb_mirror_own_webcam', MIRROR_WEBCAM);
|
|
return isOwnWebcam && isEnabledMirroring;
|
|
}
|
|
|
|
isPinEnabled() {
|
|
return PIN_WEBCAM;
|
|
}
|
|
|
|
// In user-list it is necessary to check if the user is sharing his webcam
|
|
isVideoPinEnabledForCurrentUser() {
|
|
const currentUser = Users.findOne({ userId: Auth.userID },
|
|
{ fields: { role: 1 } });
|
|
|
|
const isModerator = currentUser?.role === 'MODERATOR';
|
|
const isBreakout = meetingIsBreakout();
|
|
const isPinEnabled = this.isPinEnabled();
|
|
|
|
return !!(isModerator
|
|
&& isPinEnabled
|
|
&& !isBreakout);
|
|
}
|
|
|
|
getMyStreamId(deviceId) {
|
|
const videoStream = VideoStreams.findOne(
|
|
{
|
|
meetingId: Auth.meetingID,
|
|
userId: Auth.userID,
|
|
deviceId,
|
|
}, { fields: { stream: 1 } },
|
|
);
|
|
return videoStream ? videoStream.stream : null;
|
|
}
|
|
|
|
isUserLocked() {
|
|
return !!Users.findOne({
|
|
meetingId: Auth.meetingID,
|
|
userId: Auth.userID,
|
|
locked: true,
|
|
role: { $ne: ROLE_MODERATOR },
|
|
}, { fields: {} }) && this.disableCam();
|
|
}
|
|
|
|
lockUser() {
|
|
if (this.isConnected) {
|
|
this.exitVideo();
|
|
}
|
|
}
|
|
|
|
isLocalStream(cameraId) {
|
|
return cameraId?.startsWith(Auth.userID);
|
|
}
|
|
|
|
playStart(cameraId) {
|
|
if (this.isLocalStream(cameraId)) {
|
|
this.sendUserShareWebcam(cameraId);
|
|
this.joinedVideo();
|
|
}
|
|
}
|
|
|
|
getCameraProfile() {
|
|
const profileId = BBBStorage.getItem('WebcamProfileId') || '';
|
|
const cameraProfile = CAMERA_PROFILES.find(profile => profile.id === profileId)
|
|
|| CAMERA_PROFILES.find(profile => profile.default)
|
|
|| CAMERA_PROFILES[0];
|
|
const deviceId = BBBStorage.getItem('WebcamDeviceId');
|
|
if (deviceId) {
|
|
cameraProfile.constraints = cameraProfile.constraints || {};
|
|
cameraProfile.constraints.deviceId = { exact: deviceId };
|
|
}
|
|
|
|
return cameraProfile;
|
|
}
|
|
|
|
addCandidateToPeer(peer, candidate, cameraId) {
|
|
peer.addIceCandidate(candidate).catch((error) => {
|
|
if (error) {
|
|
// Just log the error. We can't be sure if a candidate failure on add is
|
|
// fatal or not, so that's why we have a timeout set up for negotiations
|
|
// and listeners for ICE state transitioning to failures, so we won't
|
|
// act on it here
|
|
logger.error({
|
|
logCode: 'video_provider_addicecandidate_error',
|
|
extraInfo: {
|
|
cameraId,
|
|
error,
|
|
},
|
|
}, `Adding ICE candidate failed for ${cameraId} due to ${error.message}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
processInboundIceQueue(peer, cameraId) {
|
|
while (peer.inboundIceQueue.length) {
|
|
const candidate = peer.inboundIceQueue.shift();
|
|
this.addCandidateToPeer(peer, candidate, cameraId);
|
|
}
|
|
}
|
|
|
|
onBeforeUnload() {
|
|
this.exitVideo();
|
|
}
|
|
|
|
getStatus() {
|
|
if (this.isConnecting) return 'videoConnecting';
|
|
if (this.isConnected) return 'connected';
|
|
return 'disconnected';
|
|
}
|
|
|
|
disableReason() {
|
|
const locks = {
|
|
videoLocked: this.isUserLocked(),
|
|
camCapReached: this.hasCapReached() && !this.hasVideoStream(),
|
|
meteorDisconnected: !Meteor.status().connected
|
|
};
|
|
const locksKeys = Object.keys(locks);
|
|
const disableReason = locksKeys.filter( i => locks[i]).shift();
|
|
return disableReason ? disableReason : false;
|
|
}
|
|
|
|
getRole(isLocal) {
|
|
return isLocal ? 'share' : 'viewer';
|
|
}
|
|
|
|
getUserParameterProfile() {
|
|
if (this.userParameterProfile === null) {
|
|
this.userParameterProfile = getFromUserSettings(
|
|
'bbb_preferred_camera_profile',
|
|
(CAMERA_PROFILES.find(i => i.default) || {}).id || null,
|
|
);
|
|
}
|
|
|
|
return this.userParameterProfile;
|
|
}
|
|
|
|
isMultipleCamerasEnabled() {
|
|
// Multiple cameras shouldn't be enabled with video preview skipping
|
|
// Mobile shouldn't be able to share more than one camera at the same time
|
|
// Safari needs to implement devicechange event for safe device control
|
|
return MULTIPLE_CAMERAS
|
|
&& !VideoPreviewService.getSkipVideoPreview()
|
|
&& !this.isMobile
|
|
&& !this.isSafari
|
|
&& this.numberOfDevices > 1;
|
|
}
|
|
|
|
isProfileBetter (newProfileId, originalProfileId) {
|
|
return CAMERA_PROFILES.findIndex(({ id }) => id === newProfileId)
|
|
> CAMERA_PROFILES.findIndex(({ id }) => id === originalProfileId);
|
|
}
|
|
|
|
applyBitrate (peer, bitrate) {
|
|
const peerConnection = peer.peerConnection;
|
|
if ('RTCRtpSender' in window
|
|
&& 'setParameters' in window.RTCRtpSender.prototype
|
|
&& 'getParameters' in window.RTCRtpSender.prototype) {
|
|
peerConnection.getSenders().forEach(sender => {
|
|
const { track } = sender;
|
|
if (track && track.kind === 'video') {
|
|
const parameters = sender.getParameters();
|
|
const normalizedBitrate = bitrate * 1000;
|
|
|
|
// The encoder parameters might not be up yet; if that's the case,
|
|
// add a filler object so we can alter the parameters anyways
|
|
if (parameters.encodings == null || parameters.encodings.length === 0) {
|
|
parameters.encodings = [{}];
|
|
}
|
|
|
|
// Only reset bitrate if it changed in some way to avoid enconder fluctuations
|
|
if (parameters.encodings[0].maxBitrate !== normalizedBitrate) {
|
|
parameters.encodings[0].maxBitrate = normalizedBitrate;
|
|
sender.setParameters(parameters)
|
|
.then(() => {
|
|
logger.info({
|
|
logCode: 'video_provider_bitratechange',
|
|
extraInfo: { bitrate },
|
|
}, `Bitrate changed: ${bitrate}`);
|
|
})
|
|
.catch(error => {
|
|
logger.warn({
|
|
logCode: 'video_provider_bitratechange_failed',
|
|
extraInfo: { bitrate, errorMessage: error.message, errorCode: error.code },
|
|
}, `Bitrate change failed.`);
|
|
});
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Some browsers (mainly iOS Safari) garble the stream if a constraint is
|
|
// reconfigured without propagating previous height/width info
|
|
reapplyResolutionIfNeeded (track, constraints) {
|
|
if (typeof track.getSettings !== 'function') {
|
|
return constraints;
|
|
}
|
|
|
|
const trackSettings = track.getSettings();
|
|
|
|
if (trackSettings.width && trackSettings.height) {
|
|
return {
|
|
...constraints,
|
|
width: trackSettings.width,
|
|
height: trackSettings.height,
|
|
};
|
|
}
|
|
|
|
return constraints;
|
|
}
|
|
|
|
applyCameraProfile (peer, profileId) {
|
|
const profile = CAMERA_PROFILES.find((targetProfile) => targetProfile.id === profileId);
|
|
|
|
// When this should be skipped:
|
|
// 1 - Badly defined profile
|
|
// 2 - Badly defined peer (ie {})
|
|
// 3 - The target profile is already applied
|
|
// 4 - The targetr profile is better than the original profile
|
|
if (!profile
|
|
|| peer == null
|
|
|| peer.peerConnection == null
|
|
|| peer.currentProfileId === profileId
|
|
|| this.isProfileBetter(profileId, peer.originalProfileId)) {
|
|
return;
|
|
}
|
|
|
|
const { bitrate, constraints } = profile;
|
|
|
|
if (bitrate) this.applyBitrate(peer, bitrate);
|
|
|
|
if (CAMERA_QUALITY_THR_CONSTRAINTS
|
|
&& constraints
|
|
&& typeof constraints === 'object'
|
|
) {
|
|
peer.peerConnection.getSenders().forEach((sender) => {
|
|
const { track } = sender;
|
|
if (track && track.kind === 'video' && typeof track.applyConstraints === 'function') {
|
|
const normalizedVideoConstraints = this.reapplyResolutionIfNeeded(track, constraints);
|
|
track.applyConstraints(normalizedVideoConstraints)
|
|
.catch((error) => {
|
|
logger.warn({
|
|
logCode: 'video_provider_constraintchange_failed',
|
|
extraInfo: { errorName: error.name, errorCode: error.code },
|
|
}, 'Error applying camera profile');
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
logger.info({
|
|
logCode: 'video_provider_profile_applied',
|
|
extraInfo: { profileId },
|
|
}, `New camera profile applied: ${profileId}`);
|
|
|
|
peer.currentProfileId = profileId;
|
|
}
|
|
|
|
getThreshold (numberOfPublishers) {
|
|
let targetThreshold = { threshold: 0, profile: 'original' };
|
|
let finalThreshold = { threshold: 0, profile: 'original' };
|
|
|
|
for(let mapIndex = 0; mapIndex < CAMERA_QUALITY_THRESHOLDS.length; mapIndex++) {
|
|
targetThreshold = CAMERA_QUALITY_THRESHOLDS[mapIndex];
|
|
if (targetThreshold.threshold <= numberOfPublishers) {
|
|
finalThreshold = targetThreshold;
|
|
}
|
|
}
|
|
|
|
return finalThreshold;
|
|
}
|
|
|
|
getPreloadedStream () {
|
|
if (this.deviceId == null) return;
|
|
return VideoPreviewService.getStream(this.deviceId);
|
|
}
|
|
|
|
/**
|
|
* Get all active video peers.
|
|
* @returns An Object containing the reference for all active peers peers
|
|
*/
|
|
getActivePeers() {
|
|
const videoData = this.getVideoStreams();
|
|
|
|
if (!videoData) return null;
|
|
|
|
const { streams: activeVideoStreams } = videoData;
|
|
|
|
if (!activeVideoStreams) return null;
|
|
|
|
const activePeers = {};
|
|
|
|
activeVideoStreams.forEach((stream) => {
|
|
if (this.webRtcPeersRef[stream.stream]) {
|
|
activePeers[stream.stream] = this.webRtcPeersRef[stream.stream].peerConnection;
|
|
}
|
|
});
|
|
|
|
return activePeers;
|
|
}
|
|
|
|
/**
|
|
* Get stats about all active video peer.
|
|
* We filter the status based on FILTER_VIDEO_STATS constant.
|
|
*
|
|
* For more information see:
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats
|
|
* and
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport
|
|
* @returns An Object containing the information about each active peer.
|
|
* The returned object follows the format:
|
|
* {
|
|
* peerId: RTCStatsReport
|
|
* }
|
|
*/
|
|
async getStats() {
|
|
const peers = this.getActivePeers();
|
|
|
|
if (!peers) return null;
|
|
|
|
const stats = {};
|
|
|
|
await Promise.all(
|
|
Object.keys(peers).map(async (peerId) => {
|
|
const peerStats = await peers[peerId].getStats();
|
|
|
|
const videoStats = {};
|
|
|
|
peerStats.forEach((stat) => {
|
|
if (FILTER_VIDEO_STATS.includes(stat.type)) {
|
|
videoStats[stat.type] = stat;
|
|
}
|
|
});
|
|
stats[peerId] = videoStats;
|
|
})
|
|
);
|
|
|
|
return stats;
|
|
}
|
|
|
|
updatePeerDictionaryReference(newRef) {
|
|
this.webRtcPeersRef = newRef;
|
|
}
|
|
}
|
|
|
|
const videoService = new VideoService();
|
|
|
|
export default {
|
|
storeDeviceIds: () => videoService.storeDeviceIds(),
|
|
exitVideo: () => videoService.exitVideo(),
|
|
joinVideo: deviceId => videoService.joinVideo(deviceId),
|
|
stopVideo: cameraId => videoService.stopVideo(cameraId),
|
|
getVideoStreams: () => videoService.getVideoStreams(),
|
|
getInfo: () => videoService.getInfo(),
|
|
getMyStreamId: deviceId => videoService.getMyStreamId(deviceId),
|
|
isUserLocked: () => videoService.isUserLocked(),
|
|
lockUser: () => videoService.lockUser(),
|
|
getAuthenticatedURL: () => videoService.getAuthenticatedURL(),
|
|
isLocalStream: cameraId => videoService.isLocalStream(cameraId),
|
|
hasVideoStream: () => videoService.hasVideoStream(),
|
|
getStatus: () => videoService.getStatus(),
|
|
disableReason: () => videoService.disableReason(),
|
|
playStart: cameraId => videoService.playStart(cameraId),
|
|
getCameraProfile: () => videoService.getCameraProfile(),
|
|
addCandidateToPeer: (peer, candidate, cameraId) => videoService.addCandidateToPeer(peer, candidate, cameraId),
|
|
processInboundIceQueue: (peer, cameraId) => videoService.processInboundIceQueue(peer, cameraId),
|
|
getRole: isLocal => videoService.getRole(isLocal),
|
|
getMediaServerAdapter: () => videoService.getMediaServerAdapter(),
|
|
getRecord: () => videoService.getRecord(),
|
|
getSharedDevices: () => videoService.getSharedDevices(),
|
|
getUserParameterProfile: () => videoService.getUserParameterProfile(),
|
|
isMultipleCamerasEnabled: () => videoService.isMultipleCamerasEnabled(),
|
|
mirrorOwnWebcam: userId => videoService.mirrorOwnWebcam(userId),
|
|
hasCapReached: () => videoService.hasCapReached(),
|
|
onBeforeUnload: () => videoService.onBeforeUnload(),
|
|
notify: message => notify(message, 'error', 'video'),
|
|
updateNumberOfDevices: devices => videoService.updateNumberOfDevices(devices),
|
|
applyCameraProfile: debounce(
|
|
videoService.applyCameraProfile.bind(videoService),
|
|
CAMERA_QUALITY_THR_DEBOUNCE,
|
|
{ leading: false, trailing: true },
|
|
),
|
|
getThreshold: (numberOfPublishers) => videoService.getThreshold(numberOfPublishers),
|
|
isPaginationEnabled: () => videoService.isPaginationEnabled(),
|
|
getNumberOfPages: () => videoService.getNumberOfPages(),
|
|
getCurrentVideoPageIndex: () => videoService.getCurrentVideoPageIndex(),
|
|
getPreviousVideoPage: () => videoService.getPreviousVideoPage(),
|
|
getNextVideoPage: () => videoService.getNextVideoPage(),
|
|
getPageChangeDebounceTime: () => { return PAGE_CHANGE_DEBOUNCE_TIME },
|
|
getUsersIdFromVideoStreams: () => videoService.getUsersIdFromVideoStreams(),
|
|
shouldRenderPaginationToggle: () => videoService.shouldRenderPaginationToggle(),
|
|
toggleVideoPin: (userId, pin) => videoService.toggleVideoPin(userId, pin),
|
|
getVideoPinByUser: (userId) => videoService.getVideoPinByUser(userId),
|
|
isVideoPinEnabledForCurrentUser: () => videoService.isVideoPinEnabledForCurrentUser(),
|
|
isPinEnabled: () => videoService.isPinEnabled(),
|
|
getPreloadedStream: () => videoService.getPreloadedStream(),
|
|
getStats: () => videoService.getStats(),
|
|
updatePeerDictionaryReference: (newRef) => videoService.updatePeerDictionaryReference(newRef),
|
|
};
|