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 { makeCall } from '/imports/ui/services/api'; import { notify } from '/imports/ui/services/notification'; import { monitorVideoConnection } from '/imports/utils/stats'; import browser from 'browser-detect'; import getFromUserSettings from '/imports/ui/services/users-settings'; import VideoPreviewService from '../video-preview/service'; import Storage from '/imports/ui/services/storage/session'; import logger from '/imports/startup/client/logger'; import _ from 'lodash'; 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 ENABLE_NETWORK_MONITORING = Meteor.settings.public.networkMonitoring.enableNetworkMonitoring; const MIRROR_WEBCAM = Meteor.settings.public.app.mirrorOwnWebcam; const CAMERA_QUALITY_THRESHOLDS = Meteor.settings.public.kurento.cameraQualityThresholds.thresholds || []; const { paginationToggleEnabled: PAGINATION_TOGGLE_ENABLED, pageChangeDebounceTime: PAGE_CHANGE_DEBOUNCE_TIME, desktopPageSizes: DESKTOP_PAGE_SIZES, mobilePageSizes: MOBILE_PAGE_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 TOKEN = '_'; const ENABLE_PAGINATION_SESSION_VAR = 'enablePagination'; class VideoService { // Paginated streams: sort with following priority: local -> presenter -> alphabetic static sortPaginatedStreams(s1, s2) { if (UserListService.isUserPresenter(s1.userId) && !UserListService.isUserPresenter(s2.userId)) { return -1; } else if (UserListService.isUserPresenter(s2.userId) && !UserListService.isUserPresenter(s1.userId)) { return 1; } else { return UserListService.sortUsersByName(s1, s2); } } // Full mesh: sort with the following priority: local -> alphabetic static sortMeshStreams(s1, s2) { if (s1.userId === Auth.userID && s2.userId !== Auth.userID) { return -1; } else if (s2.userId === Auth.userID && s1.userId !== Auth.userID) { return 1; } else { return UserListService.sortUsersByName(s1, s2); } } constructor() { this.defineProperties({ isConnecting: false, isConnected: false, currentVideoPageIndex: 0, numberOfPages: 0, pageSize: 0, }); this.userParameterProfile = null; const BROWSER_RESULTS = browser(); this.isMobile = BROWSER_RESULTS.mobile || BROWSER_RESULTS.os.includes('Android'); this.isSafari = BROWSER_RESULTS.name === 'safari'; 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(); } } 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; } 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 || numberOfPublishers) / 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 ((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); } getVideoPage (streams, pageSize) { // Publishers are taken into account for the page size calculations. They // also appear on every page. const [mine, others] = _.partition(streams, (vs => { return Auth.userID === vs.userId; })); // Recalculate total number of pages this.setNumberOfPages(mine.length, others.length, pageSize); const chunkIndex = this.currentVideoPageIndex * pageSize; const paginatedStreams = others .sort(VideoService.sortPaginatedStreams) .slice(chunkIndex, (chunkIndex + pageSize)) || []; const streamsOnPage = [...mine, ...paginatedStreams]; return streamsOnPage; } getVideoStreams() { let streams = VideoStreams.find( { meetingId: Auth.meetingID }, { fields: { userId: 1, stream: 1, name: 1, }, }, ).fetch(); const moderatorOnly = this.webcamsOnlyForModerator(); if (moderatorOnly) streams = this.filterModeratorOnly(streams); const connectingStream = this.getConnectingStream(streams); if (connectingStream) streams.push(connectingStream); const mappedStreams = streams.map(vs => ({ cameraId: vs.stream, userId: vs.userId, name: vs.name, })); const pageSize = this.getMyPageSize(); // 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 (!this.isPaginationEnabled() || pageSize === 0) { return { streams: mappedStreams.sort(VideoService.sortMeshStreams), totalNumberOfStreams: mappedStreams.length }; } const paginatedStreams = this.getVideoPage(mappedStreams, pageSize); return { streams: paginatedStreams, totalNumberOfStreams: mappedStreams.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); } 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 prop = Meetings.findOne( { meetingId: Auth.meetingID }, { fields: { 'metadataProp': 1 } }, ).metadataProp; const value = prop.metadata ? prop.metadata['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; } disableCam() { const m = Meetings.findOne({ meetingId: Auth.meetingID }, { fields: { 'lockSettingsProps.disableCam': 1 } }); return m.lockSettingsProps ? m.lockSettingsProps.disableCam : false; } webcamsOnlyForModerator() { const m = Meetings.findOne({ meetingId: Auth.meetingID }, { fields: { 'usersProp.webcamsOnlyForModerator': 1 } }); return m?.usersProp ? m.usersProp.webcamsOnlyForModerator : false; } 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; } getMyStream(deviceId) { const videoStream = VideoStreams.findOne( { meetingId: Auth.meetingID, userId: Auth.userID, deviceId, }, { fields: { stream: 1 } }, ); return videoStream ? videoStream.stream : null; } isUserLocked() { return !!Users.findOne({ 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 = Session.get('WebcamProfileId') || ''; const cameraProfile = CAMERA_PROFILES.find(profile => profile.id === profileId) || CAMERA_PROFILES.find(profile => profile.default) || CAMERA_PROFILES[0]; const deviceId = Session.get('WebcamDeviceId'); if (deviceId) { cameraProfile.constraints = cameraProfile.constraints || {}; cameraProfile.constraints.deviceId = { exact: deviceId }; } return cameraProfile; } addCandidateToPeer(peer, candidate, cameraId) { peer.addIceCandidate(candidate, (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(); } disableReason() { const { viewParticipantsWebcams } = Settings.dataSaving; const locks = { videoLocked: this.isUserLocked(), videoConnecting: this.isConnecting, dataSaving: !viewParticipantsWebcams, meteorDisconnected: !Meteor.status().connected }; const locksKeys = Object.keys(locks); const disableReason = locksKeys.filter( i => locks[i]).shift(); return disableReason ? disableReason : false; } getRole(isLocal) { return isLocal ? 'share' : 'viewer'; } getUserParameterProfile() { if (this.userParameterProfile === null) { this.userParameterProfile = getFromUserSettings( 'bbb_preferred_camera_profile', (CAMERA_PROFILES.filter(i => i.default) || {}).id, ); } 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; } monitor(conn) { if (ENABLE_NETWORK_MONITORING) monitorVideoConnection(conn); } // to be used soon (Paulo) amIModerator() { return Users.findOne({ userId: Auth.userID }, { fields: { role: 1 } }).role === ROLE_MODERATOR; } // to be used soon (Paulo) getNumberOfPublishers() { return VideoStreams.find({ meetingId: Auth.meetingID }).count(); } 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(); if (!parameters.encodings) { parameters.encodings = [{}]; } const normalizedBitrate = bitrate * 1000; // 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 }; } else { return constraints; } } applyCameraProfile (peer, profileId) { const profile = CAMERA_PROFILES.find(targetProfile => targetProfile.id === profileId); if (!profile) { logger.warn({ logCode: 'video_provider_noprofile', extraInfo: { profileId }, }, `Apply failed: no camera profile found.`); return; } // Profile is currently applied or it's better than the original user's profile, // skip if (peer.currentProfileId === profileId || this.isProfileBetter(profileId, peer.originalProfileId)) { return; } const { bitrate, constraints } = profile; if (bitrate) { this.applyBitrate(peer, bitrate); } if (constraints && typeof constraints === 'object') { peer.peerConnection.getSenders().forEach(sender => { const { track } = sender; if (track && track.kind === 'video' && typeof track.applyConstraints === 'function') { let normalizedVideoConstraints = this.reapplyResolutionIfNeeded(track, constraints); track.applyConstraints(normalizedVideoConstraints) .then(() => { logger.info({ logCode: 'video_provider_profile_applied', extraInfo: { profileId }, }, `New camera profile applied: ${profileId}`); peer.currentProfileId = profileId; }) .catch(error => { logger.warn({ logCode: 'video_provider_profile_apply_failed', extraInfo: { errorName: error.name, errorCode: error.code }, }, 'Error applying camera profile'); }); } }); } } 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; } } const videoService = new VideoService(); export default { exitVideo: () => videoService.exitVideo(), joinVideo: deviceId => videoService.joinVideo(deviceId), stopVideo: cameraId => videoService.stopVideo(cameraId), getVideoStreams: () => videoService.getVideoStreams(), getInfo: () => videoService.getInfo(), getMyStream: deviceId => videoService.getMyStream(deviceId), isUserLocked: () => videoService.isUserLocked(), lockUser: () => videoService.lockUser(), getAuthenticatedURL: () => videoService.getAuthenticatedURL(), isLocalStream: cameraId => videoService.isLocalStream(cameraId), hasVideoStream: () => videoService.hasVideoStream(), 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), getRecord: () => videoService.getRecord(), getSharedDevices: () => videoService.getSharedDevices(), getUserParameterProfile: () => videoService.getUserParameterProfile(), isMultipleCamerasEnabled: () => videoService.isMultipleCamerasEnabled(), monitor: conn => videoService.monitor(conn), mirrorOwnWebcam: userId => videoService.mirrorOwnWebcam(userId), onBeforeUnload: () => videoService.onBeforeUnload(), notify: message => notify(message, 'error', 'video'), updateNumberOfDevices: devices => videoService.updateNumberOfDevices(devices), applyCameraProfile: (peer, newProfile) => videoService.applyCameraProfile(peer, newProfile), 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 }, shouldRenderPaginationToggle: () => videoService.shouldRenderPaginationToggle(), };