feat(local-recording) Add self local recording (#11706)
Only record local participant audio/ video streams
This commit is contained in:
parent
b85da1e1bb
commit
a7c96e302f
|
@ -19,6 +19,10 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.space-top {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-header-line {
|
.recording-header-line {
|
||||||
|
|
|
@ -895,12 +895,17 @@
|
||||||
"linkGenerated": "We have generated a link to your recording.",
|
"linkGenerated": "We have generated a link to your recording.",
|
||||||
"live": "LIVE",
|
"live": "LIVE",
|
||||||
"localRecordingNoNotificationWarning": "The recording will not be announced to other participants. You will need to let them know that the meeting is recorded.",
|
"localRecordingNoNotificationWarning": "The recording will not be announced to other participants. You will need to let them know that the meeting is recorded.",
|
||||||
|
"localRecordingNoVideo": "Video is not being recorded",
|
||||||
|
"localRecordingVideoStop": "Stopping your video will also stop the local recording. Are you sure you want to continue?",
|
||||||
|
"localRecordingVideoWarning": "To record your video you must have it on when starting the recording",
|
||||||
"localRecordingWarning": "Make sure you select the current tab in order to use the right video and audio. The recording is currently limited to 1GB, which is around 100 minutes.",
|
"localRecordingWarning": "Make sure you select the current tab in order to use the right video and audio. The recording is currently limited to 1GB, which is around 100 minutes.",
|
||||||
"loggedIn": "Logged in as {{userName}}",
|
"loggedIn": "Logged in as {{userName}}",
|
||||||
|
"noStreams": "No audio or video stream detected.",
|
||||||
"off": "Recording stopped",
|
"off": "Recording stopped",
|
||||||
"offBy": "{{name}} stopped the recording",
|
"offBy": "{{name}} stopped the recording",
|
||||||
"on": "Recording started",
|
"on": "Recording started",
|
||||||
"onBy": "{{name}} started the recording",
|
"onBy": "{{name}} started the recording",
|
||||||
|
"onlyRecordSelf": "Record only my audio and video streams",
|
||||||
"pending": "Preparing to record the meeting...",
|
"pending": "Preparing to record the meeting...",
|
||||||
"rec": "REC",
|
"rec": "REC",
|
||||||
"saveLocalRecording": "Save recording file locally",
|
"saveLocalRecording": "Save recording file locally",
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import '../authentication/middleware';
|
import '../authentication/middleware';
|
||||||
import '../base/i18n/middleware';
|
import '../base/i18n/middleware';
|
||||||
import '../base/devices/middleware';
|
import '../base/devices/middleware';
|
||||||
|
import '../base/media/middleware';
|
||||||
import '../dynamic-branding/middleware';
|
import '../dynamic-branding/middleware';
|
||||||
import '../e2ee/middleware';
|
import '../e2ee/middleware';
|
||||||
import '../external-api/middleware';
|
import '../external-api/middleware';
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
import './middleware.any.js';
|
|
@ -0,0 +1,40 @@
|
||||||
|
import './middleware.any.js';
|
||||||
|
// @ts-ignore
|
||||||
|
import { MiddlewareRegistry } from '../redux';
|
||||||
|
import { IStore } from '../../app/types';
|
||||||
|
import { SET_VIDEO_MUTED } from './actionTypes';
|
||||||
|
import LocalRecordingManager from '../../recording/components/Recording/LocalRecordingManager.web';
|
||||||
|
// @ts-ignore
|
||||||
|
import { openDialog } from '../dialog';
|
||||||
|
// @ts-ignore
|
||||||
|
import { NOTIFICATION_TIMEOUT_TYPE, showNotification } from '../../notifications';
|
||||||
|
// @ts-ignore
|
||||||
|
import StopRecordingDialog from '../../recording/components/Recording/web/StopRecordingDialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the entry point of the middleware of the feature base/media.
|
||||||
|
*
|
||||||
|
* @param {IStore} store - The redux store.
|
||||||
|
* @returns {Function}
|
||||||
|
*/
|
||||||
|
MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: any) => {
|
||||||
|
const { dispatch } = store;
|
||||||
|
switch(action.type) {
|
||||||
|
case SET_VIDEO_MUTED: {
|
||||||
|
if (LocalRecordingManager.isRecordingLocally() && LocalRecordingManager.selfRecording.on) {
|
||||||
|
if (action.muted && LocalRecordingManager.selfRecording.withVideo) {
|
||||||
|
dispatch(openDialog(StopRecordingDialog, { localRecordingVideoStop: true }));
|
||||||
|
|
||||||
|
return;
|
||||||
|
} else if (!action.muted && !LocalRecordingManager.selfRecording.withVideo) {
|
||||||
|
dispatch(showNotification({
|
||||||
|
titleKey: 'recording.localRecordingNoVideo',
|
||||||
|
descriptionKey: 'recording.localRecordingVideoWarning',
|
||||||
|
uid: 'recording.localRecordingNoVideo'
|
||||||
|
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next(action);
|
||||||
|
});
|
|
@ -236,7 +236,8 @@ export const OVERWRITE_PARTICIPANTS_NAMES = 'OVERWRITE_PARTICIPANTS_NAMES';
|
||||||
* Updates participants local recording status.
|
* Updates participants local recording status.
|
||||||
* {
|
* {
|
||||||
* type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
|
* type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
|
||||||
* recording: boolean
|
* recording: boolean,
|
||||||
|
* onlySelf: boolean
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export const SET_LOCAL_PARTICIPANT_RECORDING_STATUS = 'SET_LOCAL_PARTICIPANT_RECORDING_STATUS';
|
export const SET_LOCAL_PARTICIPANT_RECORDING_STATUS = 'SET_LOCAL_PARTICIPANT_RECORDING_STATUS';
|
||||||
|
|
|
@ -689,14 +689,16 @@ export function overwriteParticipantsNames(participantList) {
|
||||||
* Local video recording status for the local participant.
|
* Local video recording status for the local participant.
|
||||||
*
|
*
|
||||||
* @param {boolean} recording - If local recording is ongoing.
|
* @param {boolean} recording - If local recording is ongoing.
|
||||||
|
* @param {boolean} onlySelf - If recording only local streams.
|
||||||
* @returns {{
|
* @returns {{
|
||||||
* type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
|
* type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
|
||||||
* recording: boolean
|
* recording: boolean
|
||||||
* }}
|
* }}
|
||||||
*/
|
*/
|
||||||
export function updateLocalRecordingStatus(recording) {
|
export function updateLocalRecordingStatus(recording, onlySelf) {
|
||||||
return {
|
return {
|
||||||
type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
|
type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
|
||||||
recording
|
recording,
|
||||||
|
onlySelf
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -179,11 +179,11 @@ MiddlewareRegistry.register(store => next => action => {
|
||||||
|
|
||||||
case SET_LOCAL_PARTICIPANT_RECORDING_STATUS: {
|
case SET_LOCAL_PARTICIPANT_RECORDING_STATUS: {
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
const { recording } = action;
|
const { recording, onlySelf } = action;
|
||||||
const localId = getLocalParticipant(state)?.id;
|
const localId = getLocalParticipant(state)?.id;
|
||||||
const { localRecording } = state['features/base/config'];
|
const { localRecording } = state['features/base/config'];
|
||||||
|
|
||||||
if (localRecording.notifyAllParticipants) {
|
if (localRecording?.notifyAllParticipants && !onlySelf) {
|
||||||
store.dispatch(participantUpdated({
|
store.dispatch(participantUpdated({
|
||||||
// XXX Only the local participant is allowed to update without
|
// XXX Only the local participant is allowed to update without
|
||||||
// stating the JitsiConference instance (i.e. participant property
|
// stating the JitsiConference instance (i.e. participant property
|
||||||
|
|
|
@ -71,7 +71,8 @@ export const SET_MEETING_HIGHLIGHT_BUTTON_STATE = 'SET_MEETING_HIGHLIGHT_BUTTON_
|
||||||
* Attempts to start the local recording.
|
* Attempts to start the local recording.
|
||||||
*
|
*
|
||||||
* {
|
* {
|
||||||
* type: START_LOCAL_RECORDING
|
* type: START_LOCAL_RECORDING,
|
||||||
|
* onlySelf: boolean
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export const START_LOCAL_RECORDING = 'START_LOCAL_RECORDING';
|
export const START_LOCAL_RECORDING = 'START_LOCAL_RECORDING';
|
||||||
|
|
|
@ -338,11 +338,13 @@ function _setPendingRecordingNotificationUid(uid: ?number, streamType: string) {
|
||||||
/**
|
/**
|
||||||
* Starts local recording.
|
* Starts local recording.
|
||||||
*
|
*
|
||||||
|
* @param {boolean} onlySelf - Whether to only record the local streams.
|
||||||
* @returns {Object}
|
* @returns {Object}
|
||||||
*/
|
*/
|
||||||
export function startLocalVideoRecording() {
|
export function startLocalVideoRecording(onlySelf) {
|
||||||
return {
|
return {
|
||||||
type: START_LOCAL_RECORDING
|
type: START_LOCAL_RECORDING,
|
||||||
|
onlySelf
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -139,6 +139,7 @@ class AbstractStartRecordingDialog extends Component<Props, State> {
|
||||||
= this._onSelectedRecordingServiceChanged.bind(this);
|
= this._onSelectedRecordingServiceChanged.bind(this);
|
||||||
this._onSharingSettingChanged = this._onSharingSettingChanged.bind(this);
|
this._onSharingSettingChanged = this._onSharingSettingChanged.bind(this);
|
||||||
this._toggleScreenshotCapture = this._toggleScreenshotCapture.bind(this);
|
this._toggleScreenshotCapture = this._toggleScreenshotCapture.bind(this);
|
||||||
|
this._onLocalRecordingSelfChange = this._onLocalRecordingSelfChange.bind(this);
|
||||||
|
|
||||||
let selectedRecordingService;
|
let selectedRecordingService;
|
||||||
|
|
||||||
|
@ -157,7 +158,8 @@ class AbstractStartRecordingDialog extends Component<Props, State> {
|
||||||
userName: undefined,
|
userName: undefined,
|
||||||
sharingEnabled: true,
|
sharingEnabled: true,
|
||||||
spaceLeft: undefined,
|
spaceLeft: undefined,
|
||||||
selectedRecordingService
|
selectedRecordingService,
|
||||||
|
localRecordingOnlySelf: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,6 +213,19 @@ class AbstractStartRecordingDialog extends Component<Props, State> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onLocalRecordingSelfChange: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback to handle local recording only self setting change.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
_onLocalRecordingSelfChange() {
|
||||||
|
this.setState({
|
||||||
|
localRecordingOnlySelf: !this.state.localRecordingOnlySelf
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_onSelectedRecordingServiceChanged: (string) => void;
|
_onSelectedRecordingServiceChanged: (string) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -326,7 +341,7 @@ class AbstractStartRecordingDialog extends Component<Props, State> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case RECORDING_TYPES.LOCAL: {
|
case RECORDING_TYPES.LOCAL: {
|
||||||
dispatch(startLocalVideoRecording());
|
dispatch(startLocalVideoRecording(this.state.localRecordingOnlySelf));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
sendAnalytics
|
sendAnalytics
|
||||||
} from '../../../analytics';
|
} from '../../../analytics';
|
||||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||||
|
import { setVideoMuted } from '../../../base/media';
|
||||||
import { stopLocalVideoRecording } from '../../actions';
|
import { stopLocalVideoRecording } from '../../actions';
|
||||||
import { getActiveSession } from '../../functions';
|
import { getActiveSession } from '../../functions';
|
||||||
|
|
||||||
|
@ -38,6 +39,11 @@ export type Props = {
|
||||||
*/
|
*/
|
||||||
dispatch: Function,
|
dispatch: Function,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user trying to stop the video while local recording is running.
|
||||||
|
*/
|
||||||
|
localRecordingVideoStop?: boolean,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoked to obtain translated strings.
|
* Invoked to obtain translated strings.
|
||||||
*/
|
*/
|
||||||
|
@ -78,6 +84,9 @@ export default class AbstractStopRecordingDialog<P: Props>
|
||||||
|
|
||||||
if (this.props._localRecording) {
|
if (this.props._localRecording) {
|
||||||
this.props.dispatch(stopLocalVideoRecording());
|
this.props.dispatch(stopLocalVideoRecording());
|
||||||
|
if (this.props.localRecordingVideoStop) {
|
||||||
|
this.props.dispatch(setVideoMuted(true));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const { _fileRecordingSession } = this.props;
|
const { _fileRecordingSession } = this.props;
|
||||||
|
|
||||||
|
|
|
@ -6,16 +6,23 @@ import { getRoomName } from '../../../base/conference';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { MEDIA_TYPE } from '../../../base/media';
|
import { MEDIA_TYPE } from '../../../base/media';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { getTrackState } from '../../../base/tracks';
|
import { getTrackState, getLocalTrack } from '../../../base/tracks';
|
||||||
import { inIframe } from '../../../base/util/iframeUtils';
|
import { inIframe } from '../../../base/util/iframeUtils';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { stopLocalVideoRecording } from '../../actions.any';
|
import { stopLocalVideoRecording } from '../../actions.any';
|
||||||
|
|
||||||
|
declare var APP: any;
|
||||||
|
|
||||||
interface IReduxStore {
|
interface IReduxStore {
|
||||||
dispatch: Function;
|
dispatch: Function;
|
||||||
getState: Function;
|
getState: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelfRecording {
|
||||||
|
on: boolean;
|
||||||
|
withVideo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ILocalRecordingManager {
|
interface ILocalRecordingManager {
|
||||||
recordingData: Blob[];
|
recordingData: Blob[];
|
||||||
recorder: MediaRecorder|undefined;
|
recorder: MediaRecorder|undefined;
|
||||||
|
@ -30,9 +37,10 @@ interface ILocalRecordingManager {
|
||||||
getFilename: () => string;
|
getFilename: () => string;
|
||||||
saveRecording: (recordingData: Blob[], filename: string) => void;
|
saveRecording: (recordingData: Blob[], filename: string) => void;
|
||||||
stopLocalRecording: () => void;
|
stopLocalRecording: () => void;
|
||||||
startLocalRecording: (store: IReduxStore) => void;
|
startLocalRecording: (store: IReduxStore, onlySelf: boolean) => void;
|
||||||
isRecordingLocally: () => boolean;
|
isRecordingLocally: () => boolean;
|
||||||
totalSize: number;
|
totalSize: number;
|
||||||
|
selfRecording: SelfRecording;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMimeType = (): string => {
|
const getMimeType = (): string => {
|
||||||
|
@ -63,6 +71,10 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||||
audioDestination: undefined,
|
audioDestination: undefined,
|
||||||
roomName: '',
|
roomName: '',
|
||||||
totalSize: 1073741824, // 1GB in bytes
|
totalSize: 1073741824, // 1GB in bytes
|
||||||
|
selfRecording: {
|
||||||
|
on: false,
|
||||||
|
withVideo: false
|
||||||
|
},
|
||||||
|
|
||||||
get mediaType() {
|
get mediaType() {
|
||||||
if (!preferredMediaType) {
|
if (!preferredMediaType) {
|
||||||
|
@ -93,6 +105,9 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||||
* Adds audio track to the recording stream.
|
* Adds audio track to the recording stream.
|
||||||
*/
|
*/
|
||||||
addAudioTrackToLocalRecording(track) {
|
addAudioTrackToLocalRecording(track) {
|
||||||
|
if(this.selfRecording.on) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (track) {
|
if (track) {
|
||||||
const stream = new MediaStream([ track ]);
|
const stream = new MediaStream([ track ]);
|
||||||
|
|
||||||
|
@ -143,58 +158,85 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||||
/**
|
/**
|
||||||
* Starts a local recording.
|
* Starts a local recording.
|
||||||
*/
|
*/
|
||||||
async startLocalRecording(store) {
|
async startLocalRecording(store, onlySelf) {
|
||||||
const { dispatch, getState } = store;
|
const { dispatch, getState } = store;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const supportsCaptureHandle = Boolean(navigator.mediaDevices.setCaptureHandleConfig) && !inIframe();
|
const supportsCaptureHandle = Boolean(navigator.mediaDevices.setCaptureHandleConfig) && !inIframe();
|
||||||
const tabId = uuidV4();
|
const tabId = uuidV4();
|
||||||
|
|
||||||
if (supportsCaptureHandle) {
|
this.selfRecording.on = onlySelf;
|
||||||
// @ts-ignore
|
|
||||||
navigator.mediaDevices.setCaptureHandleConfig({
|
|
||||||
handle: `JitsiMeet-${tabId}`,
|
|
||||||
permittedOrigins: [ '*' ]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.recordingData = [];
|
this.recordingData = [];
|
||||||
// @ts-ignore
|
|
||||||
const gdmStream = await navigator.mediaDevices.getDisplayMedia({
|
|
||||||
// @ts-ignore
|
|
||||||
video: { displaySurface: 'browser', frameRate: 30 },
|
|
||||||
audio: {
|
|
||||||
autoGainControl: false,
|
|
||||||
channelCount: 2,
|
|
||||||
echoCancellation: false,
|
|
||||||
noiseSuppression: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// @ts-ignore
|
|
||||||
const isBrowser = gdmStream.getVideoTracks()[0].getSettings().displaySurface === 'browser';
|
|
||||||
|
|
||||||
if (!isBrowser || (supportsCaptureHandle // @ts-ignore
|
|
||||||
&& gdmStream.getVideoTracks()[0].getCaptureHandle()?.handle !== `JitsiMeet-${tabId}`)) {
|
|
||||||
gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
|
||||||
throw new Error('WrongSurfaceSelected');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initializeAudioMixer();
|
|
||||||
this.mixAudioStream(gdmStream);
|
|
||||||
this.roomName = getRoomName(getState());
|
this.roomName = getRoomName(getState());
|
||||||
|
let gdmStream: MediaStream = new MediaStream();
|
||||||
const tracks = getTrackState(getState());
|
const tracks = getTrackState(getState());
|
||||||
|
|
||||||
tracks.forEach((track: any) => {
|
if(onlySelf) {
|
||||||
if (track.mediaType === MEDIA_TYPE.AUDIO) {
|
let audioTrack: MediaStreamTrack | undefined = getLocalTrack(tracks, MEDIA_TYPE.AUDIO)?.jitsiTrack?.track;
|
||||||
const audioTrack = track?.jitsiTrack?.track;
|
let videoTrack: MediaStreamTrack | undefined = getLocalTrack(tracks, MEDIA_TYPE.VIDEO)?.jitsiTrack?.track;
|
||||||
|
if(!audioTrack) {
|
||||||
this.addAudioTrackToLocalRecording(audioTrack);
|
APP.conference.muteAudio(false);
|
||||||
|
setTimeout(() => APP.conference.muteAudio(true), 100);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(videoTrack && videoTrack.readyState !== 'live') {
|
||||||
|
videoTrack = undefined;
|
||||||
|
}
|
||||||
|
audioTrack = getLocalTrack(getTrackState(getState()), MEDIA_TYPE.AUDIO)?.jitsiTrack?.track;
|
||||||
|
if(!audioTrack && !videoTrack) {
|
||||||
|
throw new Error('NoLocalStreams')
|
||||||
|
}
|
||||||
|
this.selfRecording.withVideo = Boolean(videoTrack);
|
||||||
|
const localTracks = [];
|
||||||
|
audioTrack && localTracks.push(audioTrack);
|
||||||
|
videoTrack && localTracks.push(videoTrack);
|
||||||
|
this.stream = new MediaStream(localTracks);
|
||||||
|
} else {
|
||||||
|
if (supportsCaptureHandle) {
|
||||||
|
// @ts-ignore
|
||||||
|
navigator.mediaDevices.setCaptureHandleConfig({
|
||||||
|
handle: `JitsiMeet-${tabId}`,
|
||||||
|
permittedOrigins: [ '*' ]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
this.stream = new MediaStream([
|
// @ts-ignore
|
||||||
...(this.audioDestination?.stream.getAudioTracks() || []),
|
gdmStream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
gdmStream.getVideoTracks()[0]
|
// @ts-ignore
|
||||||
]);
|
video: { displaySurface: 'browser', frameRate: 30 },
|
||||||
|
audio: {
|
||||||
|
autoGainControl: false,
|
||||||
|
channelCount: 2,
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// @ts-ignore
|
||||||
|
const isBrowser = gdmStream.getVideoTracks()[0].getSettings().displaySurface === 'browser';
|
||||||
|
|
||||||
|
if (!isBrowser || (supportsCaptureHandle // @ts-ignore
|
||||||
|
&& gdmStream.getVideoTracks()[0].getCaptureHandle()?.handle !== `JitsiMeet-${tabId}`)) {
|
||||||
|
gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||||
|
throw new Error('WrongSurfaceSelected');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initializeAudioMixer();
|
||||||
|
this.mixAudioStream(gdmStream);
|
||||||
|
|
||||||
|
tracks.forEach((track: any) => {
|
||||||
|
if (track.mediaType === MEDIA_TYPE.AUDIO) {
|
||||||
|
const audioTrack = track?.jitsiTrack?.track;
|
||||||
|
|
||||||
|
this.addAudioTrackToLocalRecording(audioTrack);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.stream = new MediaStream([
|
||||||
|
...(this.audioDestination?.stream.getAudioTracks() || []),
|
||||||
|
gdmStream.getVideoTracks()[0]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
this.recorder = new MediaRecorder(this.stream, {
|
this.recorder = new MediaRecorder(this.stream, {
|
||||||
mimeType: this.mediaType,
|
mimeType: this.mediaType,
|
||||||
videoBitsPerSecond: VIDEO_BIT_RATE
|
videoBitsPerSecond: VIDEO_BIT_RATE
|
||||||
|
@ -209,18 +251,20 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.recorder.addEventListener('stop', () => {
|
if(!onlySelf) {
|
||||||
this.stream?.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
this.recorder.addEventListener('stop', () => {
|
||||||
gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
this.stream?.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||||
});
|
gdmStream?.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||||
|
});
|
||||||
|
|
||||||
gdmStream.addEventListener('inactive', () => {
|
gdmStream?.addEventListener('inactive', () => {
|
||||||
dispatch(stopLocalVideoRecording());
|
dispatch(stopLocalVideoRecording());
|
||||||
});
|
});
|
||||||
|
|
||||||
this.stream.addEventListener('inactive', () => {
|
this.stream.addEventListener('inactive', () => {
|
||||||
dispatch(stopLocalVideoRecording());
|
dispatch(stopLocalVideoRecording());
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.recorder.start(5000);
|
this.recorder.start(5000);
|
||||||
},
|
},
|
||||||
|
|
|
@ -96,12 +96,22 @@ type Props = {
|
||||||
*/
|
*/
|
||||||
isVpaas: boolean,
|
isVpaas: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not we should only record the local streams.
|
||||||
|
*/
|
||||||
|
localRecordingOnlySelf: boolean,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The function will be called when there are changes related to the
|
* The function will be called when there are changes related to the
|
||||||
* switches.
|
* switches.
|
||||||
*/
|
*/
|
||||||
onChange: Function,
|
onChange: Function,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback to change the local recording only self setting.
|
||||||
|
*/
|
||||||
|
onLocalRecordingSelfChange: Function,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback to be invoked on sharing setting change.
|
* Callback to be invoked on sharing setting change.
|
||||||
*/
|
*/
|
||||||
|
@ -629,45 +639,76 @@ class StartRecordingDialogContent extends Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<>
|
||||||
<Container
|
<Container>
|
||||||
className = 'recording-header recording-header-line'
|
|
||||||
style = { styles.header }>
|
|
||||||
<Container
|
<Container
|
||||||
className = 'recording-icon-container'>
|
className = 'recording-header recording-header-line'
|
||||||
<Image
|
style = { styles.header }>
|
||||||
className = 'recording-icon'
|
<Container
|
||||||
src = { LOCAL_RECORDING }
|
className = 'recording-icon-container'>
|
||||||
style = { styles.recordingIcon } />
|
<Image
|
||||||
|
className = 'recording-icon'
|
||||||
|
src = { LOCAL_RECORDING }
|
||||||
|
style = { styles.recordingIcon } />
|
||||||
|
</Container>
|
||||||
|
<Text
|
||||||
|
className = 'recording-title'
|
||||||
|
style = {{
|
||||||
|
..._dialogStyles.text,
|
||||||
|
...styles.title
|
||||||
|
}}>
|
||||||
|
{ t('recording.saveLocalRecording') }
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
className = 'recording-switch'
|
||||||
|
disabled = { isValidating }
|
||||||
|
onValueChange = { this._onLocalRecordingSwitchChange }
|
||||||
|
style = { styles.switch }
|
||||||
|
trackColor = {{ false: TRACK_COLOR }}
|
||||||
|
value = { this.props.selectedRecordingService
|
||||||
|
=== RECORDING_TYPES.LOCAL } />
|
||||||
</Container>
|
</Container>
|
||||||
<Text
|
|
||||||
className = 'recording-title'
|
|
||||||
style = {{
|
|
||||||
..._dialogStyles.text,
|
|
||||||
...styles.title
|
|
||||||
}}>
|
|
||||||
{ t('recording.saveLocalRecording') }
|
|
||||||
</Text>
|
|
||||||
<Switch
|
|
||||||
className = 'recording-switch'
|
|
||||||
disabled = { isValidating }
|
|
||||||
onValueChange = { this._onLocalRecordingSwitchChange }
|
|
||||||
style = { styles.switch }
|
|
||||||
trackColor = {{ false: TRACK_COLOR }}
|
|
||||||
value = { this.props.selectedRecordingService
|
|
||||||
=== RECORDING_TYPES.LOCAL } />
|
|
||||||
</Container>
|
</Container>
|
||||||
{selectedRecordingService === RECORDING_TYPES.LOCAL
|
{selectedRecordingService === RECORDING_TYPES.LOCAL && (
|
||||||
&& <>
|
<>
|
||||||
|
<Container>
|
||||||
|
<Container
|
||||||
|
className = 'recording-header space-top'
|
||||||
|
style = { styles.header }>
|
||||||
|
<Container className = 'recording-icon-container file-sharing-icon-container'>
|
||||||
|
<Image
|
||||||
|
className = 'recording-file-sharing-icon'
|
||||||
|
src = { ICON_USERS }
|
||||||
|
style = { styles.recordingIcon } />
|
||||||
|
</Container>
|
||||||
|
<Text
|
||||||
|
className = 'recording-title'
|
||||||
|
style = {{
|
||||||
|
..._dialogStyles.text,
|
||||||
|
...styles.title
|
||||||
|
}}>
|
||||||
|
{t('recording.onlyRecordSelf')}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
className = 'recording-switch'
|
||||||
|
disabled = { isValidating }
|
||||||
|
onValueChange = { this.props.onLocalRecordingSelfChange }
|
||||||
|
style = { styles.switch }
|
||||||
|
trackColor = {{ false: TRACK_COLOR }}
|
||||||
|
value = { this.props.localRecordingOnlySelf } />
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
<Text className = 'local-recording-warning text'>
|
<Text className = 'local-recording-warning text'>
|
||||||
{t('recording.localRecordingWarning')}
|
{t('recording.localRecordingWarning')}
|
||||||
</Text>
|
</Text>
|
||||||
{_localRecordingNoNotification && <Text className = 'local-recording-warning notification'>
|
{_localRecordingNoNotification && !this.props.localRecordingOnlySelf
|
||||||
{t('recording.localRecordingNoNotificationWarning')}
|
&& <Text className = 'local-recording-warning notification'>
|
||||||
</Text>}
|
{t('recording.localRecordingNoNotificationWarning')}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
</Container>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -707,8 +748,8 @@ function _mapStateToProps(state) {
|
||||||
return {
|
return {
|
||||||
..._abstractMapStateToProps(state),
|
..._abstractMapStateToProps(state),
|
||||||
isVpaas: isVpaasMeeting(state),
|
isVpaas: isVpaasMeeting(state),
|
||||||
_localRecordingEnabled: !state['features/base/config'].localRecording.disable,
|
_localRecordingEnabled: !state['features/base/config'].localRecording?.disable,
|
||||||
_localRecordingNoNotification: !state['features/base/config'].localRecording.notifyAllParticipants,
|
_localRecordingNoNotification: !state['features/base/config'].localRecording?.notifyAllParticipants,
|
||||||
_styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent')
|
_styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,6 +55,7 @@ class StartRecordingDialog extends AbstractStartRecordingDialog {
|
||||||
const {
|
const {
|
||||||
isTokenValid,
|
isTokenValid,
|
||||||
isValidating,
|
isValidating,
|
||||||
|
localRecordingOnlySelf,
|
||||||
selectedRecordingService,
|
selectedRecordingService,
|
||||||
sharingEnabled,
|
sharingEnabled,
|
||||||
spaceLeft,
|
spaceLeft,
|
||||||
|
@ -78,7 +79,9 @@ class StartRecordingDialog extends AbstractStartRecordingDialog {
|
||||||
integrationsEnabled = { this._areIntegrationsEnabled() }
|
integrationsEnabled = { this._areIntegrationsEnabled() }
|
||||||
isTokenValid = { isTokenValid }
|
isTokenValid = { isTokenValid }
|
||||||
isValidating = { isValidating }
|
isValidating = { isValidating }
|
||||||
|
localRecordingOnlySelf = { localRecordingOnlySelf }
|
||||||
onChange = { this._onSelectedRecordingServiceChanged }
|
onChange = { this._onSelectedRecordingServiceChanged }
|
||||||
|
onLocalRecordingSelfChange = { this._onLocalRecordingSelfChange }
|
||||||
onSharingSettingChanged = { this._onSharingSettingChanged }
|
onSharingSettingChanged = { this._onSharingSettingChanged }
|
||||||
selectedRecordingService = { selectedRecordingService }
|
selectedRecordingService = { selectedRecordingService }
|
||||||
sharingSetting = { sharingEnabled }
|
sharingSetting = { sharingEnabled }
|
||||||
|
@ -105,6 +108,7 @@ class StartRecordingDialog extends AbstractStartRecordingDialog {
|
||||||
_onSubmit: () => boolean;
|
_onSubmit: () => boolean;
|
||||||
_onSelectedRecordingServiceChanged: (string) => void;
|
_onSelectedRecordingServiceChanged: (string) => void;
|
||||||
_onSharingSettingChanged: () => void;
|
_onSharingSettingChanged: () => void;
|
||||||
|
_onLocalRecordingSelfChange: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -25,7 +25,7 @@ class StopRecordingDialog extends AbstractStopRecordingDialog<Props> {
|
||||||
* @returns {ReactElement}
|
* @returns {ReactElement}
|
||||||
*/
|
*/
|
||||||
render() {
|
render() {
|
||||||
const { t } = this.props;
|
const { t, localRecordingVideoStop } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
@ -33,7 +33,7 @@ class StopRecordingDialog extends AbstractStopRecordingDialog<Props> {
|
||||||
onSubmit = { this._onSubmit }
|
onSubmit = { this._onSubmit }
|
||||||
titleKey = 'dialog.recording'
|
titleKey = 'dialog.recording'
|
||||||
width = 'small'>
|
width = 'small'>
|
||||||
{ t('dialog.stopRecordingWarning') }
|
{t(localRecordingVideoStop ? 'recording.localRecordingVideoStop' : 'dialog.stopRecordingWarning') }
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -133,27 +133,35 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => async action =>
|
||||||
|
|
||||||
case START_LOCAL_RECORDING: {
|
case START_LOCAL_RECORDING: {
|
||||||
const { localRecording } = getState()['features/base/config'];
|
const { localRecording } = getState()['features/base/config'];
|
||||||
|
const { onlySelf } = action;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await LocalRecordingManager.startLocalRecording({ dispatch,
|
await LocalRecordingManager.startLocalRecording({ dispatch,
|
||||||
getState });
|
getState }, action.onlySelf);
|
||||||
const props = {
|
const props = {
|
||||||
descriptionKey: 'recording.on',
|
descriptionKey: 'recording.on',
|
||||||
titleKey: 'dialog.recording'
|
titleKey: 'dialog.recording'
|
||||||
};
|
};
|
||||||
|
|
||||||
if (localRecording.notifyAllParticipants) {
|
if (localRecording?.notifyAllParticipants && !onlySelf) {
|
||||||
dispatch(playSound(RECORDING_ON_SOUND_ID));
|
dispatch(playSound(RECORDING_ON_SOUND_ID));
|
||||||
}
|
}
|
||||||
dispatch(showNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
dispatch(showNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||||
dispatch(updateLocalRecordingStatus(true));
|
dispatch(updateLocalRecordingStatus(true, onlySelf));
|
||||||
sendAnalytics(createRecordingEvent('started', 'local'));
|
sendAnalytics(createRecordingEvent('started', `local${onlySelf ? '.self' : ''}`));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Capture failed', err);
|
logger.error('Capture failed', err);
|
||||||
|
|
||||||
const noTabError = err.message === 'WrongSurfaceSelected';
|
let descriptionKey = 'recording.error';
|
||||||
|
|
||||||
|
if (err.message === 'WrongSurfaceSelected') {
|
||||||
|
descriptionKey = 'recording.surfaceError';
|
||||||
|
|
||||||
|
} else if (err.message === 'NoLocalStreams') {
|
||||||
|
descriptionKey = 'recording.noStreams';
|
||||||
|
}
|
||||||
const props = {
|
const props = {
|
||||||
descriptionKey: noTabError ? 'recording.surfaceError' : 'recording.error',
|
descriptionKey,
|
||||||
titleKey: 'recording.failedToStart'
|
titleKey: 'recording.failedToStart'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -164,11 +172,12 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => async action =>
|
||||||
|
|
||||||
case STOP_LOCAL_RECORDING: {
|
case STOP_LOCAL_RECORDING: {
|
||||||
const { localRecording } = getState()['features/base/config'];
|
const { localRecording } = getState()['features/base/config'];
|
||||||
|
const { onlySelf } = action;
|
||||||
|
|
||||||
if (LocalRecordingManager.isRecordingLocally()) {
|
if (LocalRecordingManager.isRecordingLocally()) {
|
||||||
LocalRecordingManager.stopLocalRecording();
|
LocalRecordingManager.stopLocalRecording();
|
||||||
dispatch(updateLocalRecordingStatus(false));
|
dispatch(updateLocalRecordingStatus(false));
|
||||||
if (localRecording.notifyAllParticipants) {
|
if (localRecording?.notifyAllParticipants && !onlySelf) {
|
||||||
dispatch(playSound(RECORDING_OFF_SOUND_ID));
|
dispatch(playSound(RECORDING_OFF_SOUND_ID));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue