2021-05-10 20:06:19 +00:00
|
|
|
// @flow
|
|
|
|
|
|
|
|
import debounce from 'lodash/debounce';
|
|
|
|
|
|
|
|
import { _handleParticipantError } from '../base/conference';
|
2021-12-16 17:49:36 +00:00
|
|
|
import { getSourceNameSignalingFeatureFlag } from '../base/config';
|
|
|
|
import { MEDIA_TYPE } from '../base/media';
|
|
|
|
import { getLocalParticipant, getParticipantCount } from '../base/participants';
|
2021-05-10 20:06:19 +00:00
|
|
|
import { StateListenerRegistry } from '../base/redux';
|
2022-04-29 14:32:16 +00:00
|
|
|
import { getRemoteScreenSharesSourceNames, getTrackSourceNameByMediaTypeAndParticipant } from '../base/tracks';
|
2021-05-10 20:06:19 +00:00
|
|
|
import { reportError } from '../base/util';
|
2022-03-29 08:45:09 +00:00
|
|
|
import { getActiveParticipantsIds } from '../filmstrip/functions.web';
|
2022-03-16 14:57:30 +00:00
|
|
|
import {
|
|
|
|
getVideoQualityForLargeVideo,
|
|
|
|
getVideoQualityForResizableFilmstripThumbnails,
|
2022-03-29 08:45:09 +00:00
|
|
|
getVideoQualityForStageThumbnails,
|
2022-03-16 14:57:30 +00:00
|
|
|
shouldDisplayTileView
|
|
|
|
} from '../video-layout';
|
2021-05-10 20:06:19 +00:00
|
|
|
|
|
|
|
import { setMaxReceiverVideoQuality } from './actions';
|
|
|
|
import { VIDEO_QUALITY_LEVELS } from './constants';
|
|
|
|
import { getReceiverVideoQualityLevel } from './functions';
|
|
|
|
import logger from './logger';
|
|
|
|
import { getMinHeightForQualityLvlMap } from './selector';
|
|
|
|
|
|
|
|
declare var APP: Object;
|
|
|
|
|
|
|
|
/**
|
2021-05-19 23:23:40 +00:00
|
|
|
* Handles changes in the visible participants in the filmstrip. The listener is debounced
|
2021-05-10 20:06:19 +00:00
|
|
|
* so that the client doesn't end up sending too many bridge messages when the user is
|
|
|
|
* scrolling through the thumbnails prompting updates to the selected endpoints.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
2021-08-18 22:34:01 +00:00
|
|
|
/* selector */ state => state['features/filmstrip'].visibleRemoteParticipants,
|
|
|
|
/* listener */ debounce((visibleRemoteParticipants, store) => {
|
2021-05-10 20:06:19 +00:00
|
|
|
_updateReceiverVideoConstraints(store);
|
2021-05-19 23:23:40 +00:00
|
|
|
}, 100));
|
|
|
|
|
2022-03-02 03:04:44 +00:00
|
|
|
StateListenerRegistry.register(
|
|
|
|
/* selector */ state => state['features/base/tracks'],
|
|
|
|
/* listener */(remoteTracks, store) => {
|
|
|
|
_updateReceiverVideoConstraints(store);
|
|
|
|
});
|
|
|
|
|
2021-05-19 23:23:40 +00:00
|
|
|
/**
|
|
|
|
* Handles the use case when the on-stage participant has changed.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
|
|
|
state => state['features/large-video'].participantId,
|
|
|
|
(participantId, store) => {
|
|
|
|
_updateReceiverVideoConstraints(store);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles the use case when we have set some of the constraints in redux but the conference object wasn't available
|
|
|
|
* and we haven't been able to pass the constraints to lib-jitsi-meet.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
|
|
|
state => state['features/base/conference'].conference,
|
|
|
|
(conference, store) => {
|
|
|
|
_updateReceiverVideoConstraints(store);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Updates the receiver constraints when the layout changes. When we are in stage view we need to handle the
|
|
|
|
* on-stage participant differently.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
|
|
|
/* selector */ state => state['features/video-layout'].tileViewEnabled,
|
|
|
|
/* listener */ (tileViewEnabled, store) => {
|
|
|
|
_updateReceiverVideoConstraints(store);
|
|
|
|
}
|
|
|
|
);
|
2021-05-10 20:06:19 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* StateListenerRegistry provides a reliable way of detecting changes to
|
|
|
|
* lastn state and dispatching additional actions.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
|
|
|
/* selector */ state => state['features/base/lastn'].lastN,
|
|
|
|
/* listener */ (lastN, store) => {
|
|
|
|
_updateReceiverVideoConstraints(store);
|
|
|
|
});
|
|
|
|
|
2022-03-16 14:57:30 +00:00
|
|
|
/**
|
|
|
|
* Updates the receiver constraints when the tiles in the resizable filmstrip change dimensions.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
|
|
|
state => getVideoQualityForResizableFilmstripThumbnails(state),
|
|
|
|
(_, store) => {
|
|
|
|
_updateReceiverVideoConstraints(store);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2022-03-29 08:45:09 +00:00
|
|
|
/**
|
|
|
|
* Updates the receiver constraints when the stage participants change.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
2022-04-05 13:00:32 +00:00
|
|
|
state => getActiveParticipantsIds(state).sort(),
|
2022-03-29 08:45:09 +00:00
|
|
|
(_, store) => {
|
|
|
|
_updateReceiverVideoConstraints(store);
|
2022-04-05 13:00:32 +00:00
|
|
|
}, {
|
|
|
|
deepEquals: true
|
2022-03-29 08:45:09 +00:00
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2021-05-10 20:06:19 +00:00
|
|
|
/**
|
|
|
|
* StateListenerRegistry provides a reliable way of detecting changes to
|
|
|
|
* maxReceiverVideoQuality and preferredVideoQuality state and dispatching additional actions.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
|
|
|
/* selector */ state => {
|
|
|
|
const {
|
|
|
|
maxReceiverVideoQuality,
|
|
|
|
preferredVideoQuality
|
|
|
|
} = state['features/video-quality'];
|
|
|
|
|
|
|
|
return {
|
|
|
|
maxReceiverVideoQuality,
|
|
|
|
preferredVideoQuality
|
|
|
|
};
|
|
|
|
},
|
|
|
|
/* listener */ (currentState, store, previousState = {}) => {
|
|
|
|
const { maxReceiverVideoQuality, preferredVideoQuality } = currentState;
|
|
|
|
const changedPreferredVideoQuality = preferredVideoQuality !== previousState.preferredVideoQuality;
|
|
|
|
const changedReceiverVideoQuality = maxReceiverVideoQuality !== previousState.maxReceiverVideoQuality;
|
|
|
|
|
|
|
|
if (changedPreferredVideoQuality) {
|
|
|
|
_setSenderVideoConstraint(preferredVideoQuality, store);
|
|
|
|
typeof APP !== 'undefined' && APP.API.notifyVideoQualityChanged(preferredVideoQuality);
|
|
|
|
}
|
|
|
|
changedReceiverVideoQuality && _updateReceiverVideoConstraints(store);
|
2021-05-19 23:23:40 +00:00
|
|
|
}, {
|
|
|
|
deepEquals: true
|
2021-05-10 20:06:19 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Implements a state listener in order to calculate max receiver video quality.
|
|
|
|
*/
|
|
|
|
StateListenerRegistry.register(
|
|
|
|
/* selector */ state => {
|
|
|
|
const { reducedUI } = state['features/base/responsive-ui'];
|
|
|
|
const _shouldDisplayTileView = shouldDisplayTileView(state);
|
|
|
|
const thumbnailSize = state['features/filmstrip']?.tileViewDimensions?.thumbnailSize;
|
|
|
|
const participantCount = getParticipantCount(state);
|
|
|
|
|
|
|
|
return {
|
|
|
|
displayTileView: _shouldDisplayTileView,
|
|
|
|
participantCount,
|
|
|
|
reducedUI,
|
|
|
|
thumbnailHeight: thumbnailSize?.height
|
|
|
|
};
|
|
|
|
},
|
|
|
|
/* listener */ ({ displayTileView, participantCount, reducedUI, thumbnailHeight }, { dispatch, getState }) => {
|
|
|
|
const state = getState();
|
|
|
|
const { maxReceiverVideoQuality } = state['features/video-quality'];
|
|
|
|
const { maxFullResolutionParticipants = 2 } = state['features/base/config'];
|
|
|
|
|
|
|
|
let newMaxRecvVideoQuality = VIDEO_QUALITY_LEVELS.ULTRA;
|
|
|
|
|
|
|
|
if (reducedUI) {
|
|
|
|
newMaxRecvVideoQuality = VIDEO_QUALITY_LEVELS.LOW;
|
|
|
|
} else if (displayTileView && !Number.isNaN(thumbnailHeight)) {
|
|
|
|
newMaxRecvVideoQuality = getReceiverVideoQualityLevel(thumbnailHeight, getMinHeightForQualityLvlMap(state));
|
|
|
|
|
|
|
|
// Override HD level calculated for the thumbnail height when # of participants threshold is exceeded
|
|
|
|
if (maxReceiverVideoQuality !== newMaxRecvVideoQuality && maxFullResolutionParticipants !== -1) {
|
|
|
|
const override
|
|
|
|
= participantCount > maxFullResolutionParticipants
|
|
|
|
&& newMaxRecvVideoQuality > VIDEO_QUALITY_LEVELS.STANDARD;
|
|
|
|
|
|
|
|
logger.info(`Video quality level for thumbnail height: ${thumbnailHeight}, `
|
|
|
|
+ `is: ${newMaxRecvVideoQuality}, `
|
|
|
|
+ `override: ${String(override)}, `
|
|
|
|
+ `max full res N: ${maxFullResolutionParticipants}`);
|
|
|
|
|
|
|
|
if (override) {
|
|
|
|
newMaxRecvVideoQuality = VIDEO_QUALITY_LEVELS.STANDARD;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (maxReceiverVideoQuality !== newMaxRecvVideoQuality) {
|
|
|
|
dispatch(setMaxReceiverVideoQuality(newMaxRecvVideoQuality));
|
|
|
|
}
|
|
|
|
}, {
|
|
|
|
deepEquals: true
|
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Helper function for updating the preferred sender video constraint, based on the user preference.
|
|
|
|
*
|
|
|
|
* @param {number} preferred - The user preferred max frame height.
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
function _setSenderVideoConstraint(preferred, { getState }) {
|
|
|
|
const state = getState();
|
|
|
|
const { conference } = state['features/base/conference'];
|
|
|
|
|
|
|
|
if (!conference) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
logger.info(`Setting sender resolution to ${preferred}`);
|
|
|
|
conference.setSenderVideoConstraint(preferred)
|
|
|
|
.catch(error => {
|
|
|
|
_handleParticipantError(error);
|
|
|
|
reportError(error, `Changing sender resolution to ${preferred} failed.`);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Private helper to calculate the receiver video constraints and set them on the bridge channel.
|
|
|
|
*
|
|
|
|
* @param {*} store - The redux store.
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
function _updateReceiverVideoConstraints({ getState }) {
|
|
|
|
const state = getState();
|
|
|
|
const { conference } = state['features/base/conference'];
|
|
|
|
|
|
|
|
if (!conference) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const { lastN } = state['features/base/lastn'];
|
|
|
|
const { maxReceiverVideoQuality, preferredVideoQuality } = state['features/video-quality'];
|
2021-05-19 23:23:40 +00:00
|
|
|
const { participantId: largeVideoParticipantId } = state['features/large-video'];
|
2021-05-10 20:06:19 +00:00
|
|
|
const maxFrameHeight = Math.min(maxReceiverVideoQuality, preferredVideoQuality);
|
2021-11-19 16:17:54 +00:00
|
|
|
const { remoteScreenShares } = state['features/video-layout'];
|
2021-08-20 23:32:38 +00:00
|
|
|
const { visibleRemoteParticipants } = state['features/filmstrip'];
|
2021-12-16 17:49:36 +00:00
|
|
|
const tracks = state['features/base/tracks'];
|
|
|
|
const sourceNameSignaling = getSourceNameSignalingFeatureFlag(state);
|
|
|
|
const localParticipantId = getLocalParticipant(state).id;
|
2022-03-29 08:45:09 +00:00
|
|
|
const activeParticipantsIds = getActiveParticipantsIds(state);
|
2021-08-05 14:49:40 +00:00
|
|
|
|
2022-01-07 10:54:42 +00:00
|
|
|
let receiverConstraints;
|
2021-05-10 20:06:19 +00:00
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
if (sourceNameSignaling) {
|
2022-04-29 14:32:16 +00:00
|
|
|
const remoteScreenSharesSourceNames = getRemoteScreenSharesSourceNames(state, remoteScreenShares);
|
|
|
|
|
2022-01-07 10:54:42 +00:00
|
|
|
receiverConstraints = {
|
|
|
|
constraints: {},
|
|
|
|
defaultConstraints: { 'maxHeight': VIDEO_QUALITY_LEVELS.NONE },
|
|
|
|
lastN,
|
|
|
|
onStageSources: [],
|
|
|
|
selectedSources: []
|
|
|
|
};
|
2021-12-16 17:49:36 +00:00
|
|
|
const visibleRemoteTrackSourceNames = [];
|
|
|
|
let largeVideoSourceName;
|
2022-03-29 08:45:09 +00:00
|
|
|
const activeParticipantsSources = [];
|
2021-12-16 17:49:36 +00:00
|
|
|
|
|
|
|
if (visibleRemoteParticipants?.size) {
|
|
|
|
visibleRemoteParticipants.forEach(participantId => {
|
2022-04-04 18:57:58 +00:00
|
|
|
let sourceName;
|
|
|
|
|
2022-04-29 14:32:16 +00:00
|
|
|
if (remoteScreenSharesSourceNames.includes(participantId)) {
|
2022-04-04 18:57:58 +00:00
|
|
|
sourceName = participantId;
|
|
|
|
} else {
|
|
|
|
sourceName = getTrackSourceNameByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, participantId);
|
|
|
|
}
|
2021-12-16 17:49:36 +00:00
|
|
|
|
|
|
|
if (sourceName) {
|
|
|
|
visibleRemoteTrackSourceNames.push(sourceName);
|
2022-03-29 08:45:09 +00:00
|
|
|
if (activeParticipantsIds.find(id => id === participantId)) {
|
|
|
|
activeParticipantsSources.push(sourceName);
|
|
|
|
}
|
2021-12-16 17:49:36 +00:00
|
|
|
}
|
|
|
|
});
|
2021-05-19 23:23:40 +00:00
|
|
|
}
|
2021-05-10 20:06:19 +00:00
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
if (localParticipantId !== largeVideoParticipantId) {
|
2022-04-29 14:32:16 +00:00
|
|
|
if (remoteScreenSharesSourceNames.includes(largeVideoParticipantId)) {
|
2022-04-04 18:57:58 +00:00
|
|
|
largeVideoSourceName = largeVideoParticipantId;
|
|
|
|
} else {
|
|
|
|
largeVideoSourceName = getTrackSourceNameByMediaTypeAndParticipant(
|
2022-04-29 14:32:16 +00:00
|
|
|
tracks, MEDIA_TYPE.VIDEO, largeVideoParticipantId
|
2022-04-04 18:57:58 +00:00
|
|
|
);
|
|
|
|
}
|
2021-12-16 17:49:36 +00:00
|
|
|
}
|
2021-05-10 20:06:19 +00:00
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
// Tile view.
|
|
|
|
if (shouldDisplayTileView(state)) {
|
|
|
|
if (!visibleRemoteTrackSourceNames?.length) {
|
|
|
|
return;
|
|
|
|
}
|
2021-11-19 16:17:54 +00:00
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
visibleRemoteTrackSourceNames.forEach(sourceName => {
|
|
|
|
receiverConstraints.constraints[sourceName] = { 'maxHeight': maxFrameHeight };
|
|
|
|
});
|
|
|
|
|
|
|
|
// Prioritize screenshare in tile view.
|
2022-04-29 14:32:16 +00:00
|
|
|
if (remoteScreenSharesSourceNames?.length) {
|
|
|
|
receiverConstraints.selectedSources = remoteScreenSharesSourceNames;
|
2021-12-16 17:49:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Stage view.
|
|
|
|
} else {
|
|
|
|
if (!visibleRemoteTrackSourceNames?.length && !largeVideoSourceName) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (visibleRemoteTrackSourceNames?.length) {
|
2022-03-16 14:57:30 +00:00
|
|
|
const qualityLevel = getVideoQualityForResizableFilmstripThumbnails(state);
|
2022-03-29 08:45:09 +00:00
|
|
|
const stageParticipantsLevel = getVideoQualityForStageThumbnails(state);
|
2022-03-16 14:57:30 +00:00
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
visibleRemoteTrackSourceNames.forEach(sourceName => {
|
2022-03-29 08:45:09 +00:00
|
|
|
const isStageParticipant = activeParticipantsSources.find(name => name === sourceName);
|
|
|
|
const quality = Math.min(maxFrameHeight, isStageParticipant
|
|
|
|
? stageParticipantsLevel : qualityLevel);
|
|
|
|
|
|
|
|
receiverConstraints.constraints[sourceName] = { 'maxHeight': quality };
|
2021-12-16 17:49:36 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
if (largeVideoSourceName) {
|
2022-03-16 14:57:30 +00:00
|
|
|
let quality = maxFrameHeight;
|
|
|
|
|
2022-03-17 12:18:49 +00:00
|
|
|
if (navigator.product !== 'ReactNative'
|
|
|
|
&& !remoteScreenShares.find(id => id === largeVideoParticipantId)) {
|
2022-03-16 14:57:30 +00:00
|
|
|
quality = getVideoQualityForLargeVideo();
|
|
|
|
}
|
|
|
|
receiverConstraints.constraints[largeVideoSourceName] = { 'maxHeight': quality };
|
2021-12-16 17:49:36 +00:00
|
|
|
receiverConstraints.onStageSources = [ largeVideoSourceName ];
|
|
|
|
}
|
2021-05-19 23:23:40 +00:00
|
|
|
}
|
|
|
|
|
2022-04-29 14:32:16 +00:00
|
|
|
if (remoteScreenSharesSourceNames?.length) {
|
|
|
|
remoteScreenSharesSourceNames.forEach(sourceName => {
|
2022-04-08 17:51:42 +00:00
|
|
|
receiverConstraints.constraints[sourceName] = { 'maxHeight': VIDEO_QUALITY_LEVELS.ULTRA };
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
} else {
|
2022-01-07 10:54:42 +00:00
|
|
|
receiverConstraints = {
|
|
|
|
constraints: {},
|
|
|
|
defaultConstraints: { 'maxHeight': VIDEO_QUALITY_LEVELS.NONE },
|
|
|
|
lastN,
|
|
|
|
onStageEndpoints: [],
|
|
|
|
selectedEndpoints: []
|
|
|
|
};
|
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
// Tile view.
|
|
|
|
if (shouldDisplayTileView(state)) {
|
|
|
|
if (!visibleRemoteParticipants?.size) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-08-18 22:34:01 +00:00
|
|
|
visibleRemoteParticipants.forEach(participantId => {
|
2021-12-16 17:49:36 +00:00
|
|
|
receiverConstraints.constraints[participantId] = { 'maxHeight': maxFrameHeight };
|
2021-05-19 23:23:40 +00:00
|
|
|
});
|
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
// Prioritize screenshare in tile view.
|
|
|
|
remoteScreenShares?.length && (receiverConstraints.selectedEndpoints = remoteScreenShares);
|
|
|
|
|
|
|
|
// Stage view.
|
|
|
|
} else {
|
|
|
|
if (!visibleRemoteParticipants?.size && !largeVideoParticipantId) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (visibleRemoteParticipants?.size > 0) {
|
2022-03-16 14:57:30 +00:00
|
|
|
const qualityLevel = getVideoQualityForResizableFilmstripThumbnails(state);
|
2022-03-29 08:45:09 +00:00
|
|
|
const stageParticipantsLevel = getVideoQualityForStageThumbnails(state);
|
2022-03-16 14:57:30 +00:00
|
|
|
|
2021-12-16 17:49:36 +00:00
|
|
|
visibleRemoteParticipants.forEach(participantId => {
|
2022-03-29 08:45:09 +00:00
|
|
|
const isStageParticipant = activeParticipantsIds.find(id => id === participantId);
|
|
|
|
const quality = Math.min(maxFrameHeight, isStageParticipant
|
|
|
|
? stageParticipantsLevel : qualityLevel);
|
|
|
|
|
|
|
|
receiverConstraints.constraints[participantId] = { 'maxHeight': quality };
|
2021-12-16 17:49:36 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
if (largeVideoParticipantId) {
|
2022-03-16 14:57:30 +00:00
|
|
|
let quality = maxFrameHeight;
|
|
|
|
|
2022-03-17 12:18:49 +00:00
|
|
|
if (navigator.product !== 'ReactNative'
|
|
|
|
&& !remoteScreenShares.find(id => id === largeVideoParticipantId)) {
|
2022-03-16 14:57:30 +00:00
|
|
|
quality = getVideoQualityForLargeVideo();
|
|
|
|
}
|
|
|
|
receiverConstraints.constraints[largeVideoParticipantId] = { 'maxHeight': quality };
|
2021-12-16 17:49:36 +00:00
|
|
|
receiverConstraints.onStageEndpoints = [ largeVideoParticipantId ];
|
|
|
|
}
|
2021-05-19 23:23:40 +00:00
|
|
|
}
|
2021-05-10 20:06:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
conference.setReceiverConstraints(receiverConstraints);
|
|
|
|
} catch (error) {
|
|
|
|
_handleParticipantError(error);
|
|
|
|
reportError(error, `Failed to set receiver video constraints ${JSON.stringify(receiverConstraints)}`);
|
|
|
|
}
|
|
|
|
}
|