snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
139
react/features/recording/actionTypes.ts
Normal file
139
react/features/recording/actionTypes.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The type of Redux action which clears all the data of every sessions.
|
||||
*
|
||||
* {
|
||||
* type: CLEAR_RECORDING_SESSIONS
|
||||
* }
|
||||
* @public
|
||||
*/
|
||||
export const CLEAR_RECORDING_SESSIONS = 'CLEAR_RECORDING_SESSIONS';
|
||||
|
||||
/**
|
||||
* The type of Redux action which marks a session ID as consent requested.
|
||||
*
|
||||
* {
|
||||
* type: MARK_CONSENT_REQUESTED,
|
||||
* sessionId: string
|
||||
* }
|
||||
*/
|
||||
export const MARK_CONSENT_REQUESTED = 'MARK_CONSENT_REQUESTED';
|
||||
|
||||
/**
|
||||
* The type of Redux action which updates the current known state of a recording
|
||||
* session.
|
||||
*
|
||||
* {
|
||||
* type: RECORDING_SESSION_UPDATED,
|
||||
* sessionData: Object
|
||||
* }
|
||||
* @public
|
||||
*/
|
||||
export const RECORDING_SESSION_UPDATED = 'RECORDING_SESSION_UPDATED';
|
||||
|
||||
/**
|
||||
* The type of Redux action which sets the pending recording notification UID to
|
||||
* use it for when hiding the notification is necessary, or unsets it when
|
||||
* undefined (or no param) is passed.
|
||||
*
|
||||
* {
|
||||
* type: SET_PENDING_RECORDING_NOTIFICATION_UID,
|
||||
* streamType: string,
|
||||
* uid: ?number
|
||||
* }
|
||||
* @public
|
||||
*/
|
||||
export const SET_PENDING_RECORDING_NOTIFICATION_UID
|
||||
= 'SET_PENDING_RECORDING_NOTIFICATION_UID';
|
||||
|
||||
/**
|
||||
* The type of Redux action which sets the selected recording service.
|
||||
*
|
||||
* {
|
||||
* type: SET_SELECTED_RECORDING_SERVICE
|
||||
* }
|
||||
* @public
|
||||
*/
|
||||
export const SET_SELECTED_RECORDING_SERVICE = 'SET_SELECTED_RECORDING_SERVICE';
|
||||
|
||||
/**
|
||||
* Sets the stream key last used by the user for later reuse.
|
||||
*
|
||||
* {
|
||||
* type: SET_STREAM_KEY,
|
||||
* streamKey: string
|
||||
* }
|
||||
*/
|
||||
export const SET_STREAM_KEY = 'SET_STREAM_KEY';
|
||||
|
||||
/**
|
||||
* Sets the enable state of the meeting highlight button.
|
||||
*
|
||||
* {
|
||||
* type: SET_MEETING_HIGHLIGHT_BUTTON_STATE,
|
||||
* disabled: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_MEETING_HIGHLIGHT_BUTTON_STATE = 'SET_MEETING_HIGHLIGHT_BUTTON_STATE';
|
||||
|
||||
/**
|
||||
* Attempts to start the local recording.
|
||||
*
|
||||
* {
|
||||
* type: START_LOCAL_RECORDING,
|
||||
* onlySelf: boolean
|
||||
* }
|
||||
*/
|
||||
export const START_LOCAL_RECORDING = 'START_LOCAL_RECORDING';
|
||||
|
||||
/**
|
||||
* Stops local recording.
|
||||
*
|
||||
* {
|
||||
* type: STOP_LOCAL_RECORDING
|
||||
* }
|
||||
*/
|
||||
export const STOP_LOCAL_RECORDING = 'STOP_LOCAL_RECORDING';
|
||||
|
||||
/**
|
||||
* Indicates that the start recording notification has been shown.
|
||||
*
|
||||
* {
|
||||
* type: SET_START_RECORDING_NOTIFICATION_SHOWN
|
||||
* }
|
||||
*/
|
||||
export const SET_START_RECORDING_NOTIFICATION_SHOWN = 'SET_START_RECORDING_NOTIFICATION_SHOWN';
|
||||
|
||||
/**
|
||||
* Stores the user's intent when starting recording (with or without transcription).
|
||||
* Set synchronously before any async operations begin.
|
||||
*
|
||||
* {
|
||||
* type: SET_START_RECORDING_INTENT,
|
||||
* intent: Object | null
|
||||
* }
|
||||
*/
|
||||
export const SET_START_RECORDING_INTENT = 'SET_START_RECORDING_INTENT';
|
||||
|
||||
/**
|
||||
* Stores what the user is stopping (recording and/or transcription). Mirrors
|
||||
* SET_START_RECORDING_INTENT. Seeded from the stop dialog on the local side and
|
||||
* from metadata true→false transitions on remote observers.
|
||||
*
|
||||
* {
|
||||
* type: SET_STOP_RECORDING_INTENT,
|
||||
* intent: Object | null
|
||||
* }
|
||||
*/
|
||||
export const SET_STOP_RECORDING_INTENT = 'SET_STOP_RECORDING_INTENT';
|
||||
|
||||
/**
|
||||
* Tracks whether a local recording is currently active in Redux state so that
|
||||
* selectors (isRecordingRunning, getSessionStatusToShow) can react to it
|
||||
* without polling the LocalRecordingManager singleton directly.
|
||||
*
|
||||
* {
|
||||
* type: SET_LOCAL_RECORDING_RUNNING,
|
||||
* running: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_LOCAL_RECORDING_RUNNING = 'SET_LOCAL_RECORDING_RUNNING';
|
||||
608
react/features/recording/actions.any.ts
Normal file
608
react/features/recording/actions.any.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { getMeetingRegion, getRecordingSharingUrl } from '../base/config/functions';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import JitsiMeetJS, { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
|
||||
import { getLocalParticipant, getParticipantDisplayName } from '../base/participants/functions';
|
||||
import { BUTTON_TYPES } from '../base/ui/constants.any';
|
||||
import { copyText } from '../base/util/copyText';
|
||||
import { getVpaasTenant, isVpaasMeeting } from '../jaas/functions';
|
||||
import {
|
||||
hideNotification,
|
||||
showErrorNotification,
|
||||
showNotification,
|
||||
showWarningNotification
|
||||
} from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
|
||||
import { INotificationProps } from '../notifications/types';
|
||||
import { setRequestingSubtitles } from '../subtitles/actions.any';
|
||||
import { isRecorderTranscriptionsRunning } from '../transcribing/functions';
|
||||
|
||||
import {
|
||||
CLEAR_RECORDING_SESSIONS,
|
||||
MARK_CONSENT_REQUESTED,
|
||||
RECORDING_SESSION_UPDATED,
|
||||
SET_LOCAL_RECORDING_RUNNING,
|
||||
SET_MEETING_HIGHLIGHT_BUTTON_STATE,
|
||||
SET_PENDING_RECORDING_NOTIFICATION_UID,
|
||||
SET_SELECTED_RECORDING_SERVICE,
|
||||
SET_START_RECORDING_INTENT,
|
||||
SET_START_RECORDING_NOTIFICATION_SHOWN,
|
||||
SET_STOP_RECORDING_INTENT,
|
||||
SET_STREAM_KEY,
|
||||
START_LOCAL_RECORDING,
|
||||
STOP_LOCAL_RECORDING
|
||||
} from './actionTypes';
|
||||
import { RECORDING_METADATA_ID, START_RECORDING_NOTIFICATION_ID } from './constants';
|
||||
import {
|
||||
getRecordButtonProps,
|
||||
getRecordingLink,
|
||||
getResourceId,
|
||||
isRecordingRunning,
|
||||
isRecordingSharingEnabled,
|
||||
isSavingRecordingOnDropbox,
|
||||
sendMeetingHighlight,
|
||||
shouldAutoTranscribeOnRecord
|
||||
} from './functions';
|
||||
import logger from './logger';
|
||||
import { IStartRecordingIntent, IStopRecordingIntent } from './reducer';
|
||||
|
||||
|
||||
/**
|
||||
* Clears the data of every recording sessions.
|
||||
*
|
||||
* @returns {{
|
||||
* type: CLEAR_RECORDING_SESSIONS
|
||||
* }}
|
||||
*/
|
||||
export function clearRecordingSessions() {
|
||||
return {
|
||||
type: CLEAR_RECORDING_SESSIONS
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Marks the start recording notification as shown.
|
||||
*
|
||||
* @returns {{
|
||||
* type: SET_START_RECORDING_NOTIFICATION_SHOWN
|
||||
* }}
|
||||
*/
|
||||
export function setStartRecordingNotificationShown() {
|
||||
return {
|
||||
type: SET_START_RECORDING_NOTIFICATION_SHOWN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the meeting highlight button disable state.
|
||||
*
|
||||
* @param {boolean} disabled - The disabled state value.
|
||||
* @returns {{
|
||||
* type: CLEAR_RECORDING_SESSIONS
|
||||
* }}
|
||||
*/
|
||||
export function setHighlightMomentButtonState(disabled: boolean) {
|
||||
return {
|
||||
type: SET_MEETING_HIGHLIGHT_BUTTON_STATE,
|
||||
disabled
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the pending recording notification should be removed from the
|
||||
* screen.
|
||||
*
|
||||
* @param {string} streamType - The type of the stream ({@code 'file'} or
|
||||
* {@code 'stream'}).
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function hidePendingRecordingNotification(streamType: string) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const { pendingNotificationUids } = getState()['features/recording'];
|
||||
const pendingNotificationUid = pendingNotificationUids[streamType];
|
||||
|
||||
if (pendingNotificationUid) {
|
||||
dispatch(hideNotification(pendingNotificationUid));
|
||||
dispatch(
|
||||
_setPendingRecordingNotificationUid(
|
||||
undefined, streamType));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the stream key last used by the user for later reuse.
|
||||
*
|
||||
* @param {string} streamKey - The stream key to set.
|
||||
* @returns {{
|
||||
* type: SET_STREAM_KEY,
|
||||
* streamKey: string
|
||||
* }}
|
||||
*/
|
||||
export function setLiveStreamKey(streamKey: string) {
|
||||
return {
|
||||
type: SET_STREAM_KEY,
|
||||
streamKey
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the pending recording notification should be shown on the
|
||||
* screen.
|
||||
*
|
||||
* @param {string} streamType - The type of the stream ({@code file} or
|
||||
* {@code stream}).
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function showPendingRecordingNotification(streamType: string) {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
const isLiveStreaming
|
||||
= streamType === JitsiMeetJS.constants.recording.mode.STREAM;
|
||||
const dialogProps = isLiveStreaming ? {
|
||||
descriptionKey: 'liveStreaming.pending',
|
||||
titleKey: 'dialog.liveStreaming'
|
||||
} : {
|
||||
descriptionKey: 'recording.pending',
|
||||
titleKey: 'dialog.recording'
|
||||
};
|
||||
const notification = dispatch(showNotification({
|
||||
...dialogProps
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
|
||||
if (notification) {
|
||||
dispatch(_setPendingRecordingNotificationUid(notification.uid, streamType));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights a meeting moment.
|
||||
*
|
||||
* {@code stream}).
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function highlightMeetingMoment() {
|
||||
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
dispatch(setHighlightMomentButtonState(true));
|
||||
|
||||
const success = await sendMeetingHighlight(getState());
|
||||
|
||||
if (success) {
|
||||
dispatch(showNotification({
|
||||
descriptionKey: 'recording.highlightMomentSucessDescription',
|
||||
titleKey: 'recording.highlightMomentSuccess'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
}
|
||||
|
||||
dispatch(setHighlightMomentButtonState(false));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the recording error notification should be shown.
|
||||
*
|
||||
* @param {Object} props - The Props needed to render the notification.
|
||||
* @returns {showErrorNotification}
|
||||
*/
|
||||
export function showRecordingError(props: Object) {
|
||||
return showErrorNotification(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the recording warning notification should be shown.
|
||||
*
|
||||
* @param {Object} props - The Props needed to render the notification.
|
||||
* @returns {showWarningNotification}
|
||||
*/
|
||||
export function showRecordingWarning(props: Object) {
|
||||
return showWarningNotification(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the stopped recording notification should be shown on the
|
||||
* screen for a given period.
|
||||
*
|
||||
* @param {string} streamType - The type of the stream ({@code file} or
|
||||
* {@code stream}).
|
||||
* @param {string?} participantName - The participant name stopping the recording.
|
||||
* @param {boolean} wasWithTranscription - Whether transcription was active alongside
|
||||
* recording when it was started.
|
||||
* @returns {showNotification}
|
||||
*/
|
||||
export function showStoppedRecordingNotification(
|
||||
streamType: string, participantName?: string, wasWithTranscription?: boolean) {
|
||||
const isLiveStreaming
|
||||
= streamType === JitsiMeetJS.constants.recording.mode.STREAM;
|
||||
const descriptionArguments = { name: participantName };
|
||||
let dialogProps;
|
||||
|
||||
if (isLiveStreaming) {
|
||||
dialogProps = {
|
||||
descriptionKey: participantName ? 'liveStreaming.offBy' : 'liveStreaming.off',
|
||||
descriptionArguments,
|
||||
titleKey: 'dialog.liveStreaming'
|
||||
};
|
||||
} else if (wasWithTranscription) {
|
||||
dialogProps = {
|
||||
descriptionKey: participantName ? 'recording.offByWithTranscription' : 'recording.offWithTranscription',
|
||||
descriptionArguments,
|
||||
titleKey: 'dialog.recording'
|
||||
};
|
||||
} else {
|
||||
dialogProps = {
|
||||
descriptionKey: participantName ? 'recording.offBy' : 'recording.off',
|
||||
descriptionArguments,
|
||||
titleKey: 'dialog.recording'
|
||||
};
|
||||
}
|
||||
|
||||
return showNotification(dialogProps, NOTIFICATION_TIMEOUT_TYPE.SHORT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that a started recording notification should be shown on the
|
||||
* screen for a given period.
|
||||
*
|
||||
* @param {string} mode - The type of the recording: Stream of File.
|
||||
* @param {string | Object } initiator - The participant who started recording.
|
||||
* @param {string} sessionId - The recording session id.
|
||||
* @param {boolean} willTranscribe - Whether transcription is/will be active alongside recording,
|
||||
* as determined by the middleware when the sound was played. Ensures the notification text matches
|
||||
* the audio cue.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export interface INudge {
|
||||
actionNameKey: string;
|
||||
descriptionText: string;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export function showStartedRecordingNotification(
|
||||
mode: string,
|
||||
initiator: { getId: Function; } | string,
|
||||
sessionId: string,
|
||||
willTranscribe?: boolean,
|
||||
nudge?: INudge) {
|
||||
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const initiatorId = getResourceId(initiator);
|
||||
const participantName = getParticipantDisplayName(state, initiatorId);
|
||||
const notifyProps: {
|
||||
dialogProps: INotificationProps;
|
||||
type: string;
|
||||
} = {
|
||||
dialogProps: {
|
||||
descriptionKey: participantName ? 'liveStreaming.onBy' : 'liveStreaming.on',
|
||||
descriptionArguments: { name: participantName },
|
||||
titleKey: 'dialog.liveStreaming'
|
||||
},
|
||||
type: NOTIFICATION_TIMEOUT_TYPE.SHORT
|
||||
};
|
||||
|
||||
if (mode !== JitsiMeetJS.constants.recording.mode.STREAM) {
|
||||
const recordingSharingUrl = getRecordingSharingUrl(state);
|
||||
const iAmRecordingInitiator = getLocalParticipant(state)?.id === initiatorId;
|
||||
const { showRecordingLink } = state['features/base/config'].recordings || {};
|
||||
|
||||
// Use the willTranscribe value passed from the middleware for consistency
|
||||
// with the sound that was already played. Fall back to live state check
|
||||
// if not provided.
|
||||
const isTranscribing = willTranscribe ?? isRecorderTranscriptionsRunning(state);
|
||||
const isRecording = isRecordingRunning(state);
|
||||
|
||||
// Case 1: Transcription only (no recording)
|
||||
if (isTranscribing && !isRecording) {
|
||||
notifyProps.dialogProps = {
|
||||
customActionHandler: undefined,
|
||||
customActionNameKey: undefined,
|
||||
descriptionKey: participantName ? 'transcribing.onBy' : 'transcribing.on',
|
||||
descriptionArguments: { name: participantName },
|
||||
titleKey: 'dialog.recording'
|
||||
};
|
||||
} else if (isTranscribing && isRecording) {
|
||||
// Case 2: Recording + transcription
|
||||
notifyProps.dialogProps = {
|
||||
customActionHandler: undefined,
|
||||
customActionNameKey: undefined,
|
||||
descriptionKey: participantName ? 'recording.onByWithTranscription' : 'recording.onWithTranscription',
|
||||
descriptionArguments: { name: participantName },
|
||||
titleKey: 'dialog.recording'
|
||||
};
|
||||
} else {
|
||||
// Case 3: Recording only (no transcription)
|
||||
notifyProps.dialogProps = {
|
||||
customActionHandler: undefined,
|
||||
customActionNameKey: undefined,
|
||||
descriptionKey: participantName ? 'recording.onBy' : 'recording.on',
|
||||
descriptionArguments: { name: participantName },
|
||||
titleKey: 'dialog.recording'
|
||||
};
|
||||
}
|
||||
|
||||
// Merge nudge action when only one service started.
|
||||
if (nudge) {
|
||||
notifyProps.dialogProps = {
|
||||
...notifyProps.dialogProps,
|
||||
description: nudge.descriptionText,
|
||||
customActionNameKey: [ nudge.actionNameKey ],
|
||||
customActionHandler: [ nudge.handler ]
|
||||
};
|
||||
notifyProps.type = NOTIFICATION_TIMEOUT_TYPE.LONG;
|
||||
}
|
||||
|
||||
// fetch the recording link from the server for recording initiators in jaas meetings
|
||||
if (recordingSharingUrl
|
||||
&& isVpaasMeeting(state)
|
||||
&& iAmRecordingInitiator
|
||||
&& !isSavingRecordingOnDropbox(state)) {
|
||||
const region = getMeetingRegion(state);
|
||||
const tenant = getVpaasTenant(state);
|
||||
|
||||
try {
|
||||
const response = await getRecordingLink(recordingSharingUrl, sessionId, region, tenant);
|
||||
const { url: link, urlExpirationTimeMillis: ttl } = response;
|
||||
|
||||
if (typeof APP === 'object') {
|
||||
APP.API.notifyRecordingLinkAvailable(link, ttl);
|
||||
}
|
||||
|
||||
// add the option to copy recording link
|
||||
if (showRecordingLink) {
|
||||
const actions = [
|
||||
...notifyProps.dialogProps.customActionNameKey ?? [],
|
||||
'recording.copyLink'
|
||||
];
|
||||
const handlers = [
|
||||
...notifyProps.dialogProps.customActionHandler ?? [],
|
||||
() => copyText(link)
|
||||
];
|
||||
|
||||
notifyProps.dialogProps = {
|
||||
...notifyProps.dialogProps,
|
||||
customActionNameKey: actions,
|
||||
customActionHandler: handlers,
|
||||
titleKey: 'recording.on',
|
||||
descriptionKey: 'recording.linkGenerated'
|
||||
};
|
||||
|
||||
notifyProps.type = NOTIFICATION_TIMEOUT_TYPE.STICKY;
|
||||
}
|
||||
} catch (err) {
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: 'recording.errorFetchingLink'
|
||||
}));
|
||||
|
||||
return logger.error('Could not fetch recording link', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(showNotification(notifyProps.dialogProps, notifyProps.type));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the known state for a given recording session.
|
||||
*
|
||||
* @param {Object} session - The new state to merge with the existing state in
|
||||
* redux.
|
||||
* @returns {{
|
||||
* type: RECORDING_SESSION_UPDATED,
|
||||
* sessionData: Object
|
||||
* }}
|
||||
*/
|
||||
export function updateRecordingSessionData(session: any) {
|
||||
const status = session.getStatus();
|
||||
const timestamp
|
||||
= status === JitsiRecordingConstants.status.ON
|
||||
? Date.now() / 1000
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: RECORDING_SESSION_UPDATED,
|
||||
sessionData: {
|
||||
error: session.getError(),
|
||||
id: session.getID(),
|
||||
initiator: session.getInitiator(),
|
||||
liveStreamViewURL: session.getLiveStreamViewURL(),
|
||||
mode: session.getMode(),
|
||||
status,
|
||||
terminator: session.getTerminator(),
|
||||
timestamp
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected recording service.
|
||||
*
|
||||
* @param {string} selectedRecordingService - The new selected recording service.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function setSelectedRecordingService(selectedRecordingService: string) {
|
||||
return {
|
||||
type: SET_SELECTED_RECORDING_SERVICE,
|
||||
selectedRecordingService
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets UID of the the pending streaming notification to use it when hinding
|
||||
* the notification is necessary, or unsets it when undefined (or no param) is
|
||||
* passed.
|
||||
*
|
||||
* @param {?number} uid - The UID of the notification.
|
||||
* @param {string} streamType - The type of the stream ({@code file} or
|
||||
* {@code stream}).
|
||||
* @returns {{
|
||||
* type: SET_PENDING_RECORDING_NOTIFICATION_UID,
|
||||
* streamType: string,
|
||||
* uid: number
|
||||
* }}
|
||||
*/
|
||||
function _setPendingRecordingNotificationUid(uid: string | undefined, streamType: string) {
|
||||
return {
|
||||
type: SET_PENDING_RECORDING_NOTIFICATION_UID,
|
||||
streamType,
|
||||
uid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts local recording.
|
||||
*
|
||||
* @param {boolean} onlySelf - Whether to only record the local streams.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function startLocalVideoRecording(onlySelf?: boolean) {
|
||||
return {
|
||||
type: START_LOCAL_RECORDING,
|
||||
onlySelf
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops local recording.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function stopLocalVideoRecording() {
|
||||
return {
|
||||
type: STOP_LOCAL_RECORDING
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the notification suggesting to start the recording.
|
||||
*
|
||||
* @param {Function} openRecordingDialog - The callback to open the recording dialog.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function showStartRecordingNotificationWithCallback(openRecordingDialog: Function) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
let state = getState();
|
||||
const { recordings } = state['features/base/config'];
|
||||
const { suggestRecording } = recordings || {};
|
||||
const recordButtonProps = getRecordButtonProps(state);
|
||||
const isAlreadyRecording = isRecordingRunning(state) || isRecorderTranscriptionsRunning(state);
|
||||
const wasNotificationShown = state['features/recording'].wasStartRecordingSuggested;
|
||||
|
||||
if (!suggestRecording
|
||||
|| isAlreadyRecording
|
||||
|| !recordButtonProps.visible
|
||||
|| recordButtonProps.disabled
|
||||
|| wasNotificationShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setStartRecordingNotificationShown());
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.suggestRecordingTitle',
|
||||
descriptionKey: 'notify.suggestRecordingDescription',
|
||||
uid: START_RECORDING_NOTIFICATION_ID,
|
||||
customActionType: [ BUTTON_TYPES.PRIMARY ],
|
||||
customActionNameKey: [ 'notify.suggestRecordingAction' ],
|
||||
customActionHandler: [ () => {
|
||||
state = getState();
|
||||
const { recordingService } = state['features/base/config'];
|
||||
const canBypassDialog = recordingService?.enabled
|
||||
&& isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false);
|
||||
|
||||
if (canBypassDialog) {
|
||||
const options = {
|
||||
'file_recording_metadata': {
|
||||
share: isRecordingSharingEnabled(state)
|
||||
}
|
||||
};
|
||||
|
||||
const { conference } = state['features/base/conference'];
|
||||
const autoTranscribeOnRecord = shouldAutoTranscribeOnRecord(state);
|
||||
|
||||
dispatch(setStartRecordingIntent({
|
||||
recording: true,
|
||||
transcription: autoTranscribeOnRecord
|
||||
}));
|
||||
|
||||
conference?.startRecording({
|
||||
mode: JitsiRecordingConstants.mode.FILE,
|
||||
appData: JSON.stringify(options)
|
||||
});
|
||||
|
||||
if (autoTranscribeOnRecord) {
|
||||
dispatch(setRequestingSubtitles(true, false, null, false, true));
|
||||
} else {
|
||||
conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isRecordingRequested: true,
|
||||
isTranscribingEnabled: false
|
||||
});
|
||||
}
|
||||
} else {
|
||||
openRecordingDialog();
|
||||
}
|
||||
|
||||
dispatch(hideNotification(START_RECORDING_NOTIFICATION_ID));
|
||||
} ],
|
||||
appearance: NOTIFICATION_TYPE.NORMAL
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.EXTRA_LONG));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the given session as consent requested. No further consent requests will be
|
||||
* made for this session.
|
||||
*
|
||||
* @param {string} sessionId - The session id.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function markConsentRequested(sessionId: string) {
|
||||
return {
|
||||
type: MARK_CONSENT_REQUESTED,
|
||||
sessionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user's recording + transcription intent from the dialog.
|
||||
* Used to coordinate sound/notification timing when both services are requested.
|
||||
*
|
||||
* @param {Object|null} intent - The intent, or null to clear.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function setStartRecordingIntent(intent: IStartRecordingIntent | null) {
|
||||
return {
|
||||
type: SET_START_RECORDING_INTENT,
|
||||
intent
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets what the user is stopping (recording and/or transcription). Mirrors
|
||||
* {@link setStartRecordingIntent}. Consumed by {@code maybeNotifyRecordingStop}
|
||||
* to coordinate the off-sound/notification across stop events.
|
||||
*
|
||||
* @param {Object|null} intent - The intent, or null to clear.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function setStopRecordingIntent(intent: IStopRecordingIntent | null) {
|
||||
return {
|
||||
type: SET_STOP_RECORDING_INTENT,
|
||||
intent
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether a local recording is active in Redux state so that selectors
|
||||
* can react to it without polling the LocalRecordingManager singleton directly.
|
||||
*
|
||||
* @param {boolean} running - Whether local recording is now running.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function setLocalRecordingRunning(running: boolean) {
|
||||
return {
|
||||
type: SET_LOCAL_RECORDING_RUNNING,
|
||||
running
|
||||
};
|
||||
}
|
||||
33
react/features/recording/actions.native.ts
Normal file
33
react/features/recording/actions.native.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { openSheet } from '../base/dialog/actions';
|
||||
import { navigate } from '../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../mobile/navigation/routes';
|
||||
|
||||
import { showStartRecordingNotificationWithCallback } from './actions.any';
|
||||
import HighlightDialog from './components/Recording/native/HighlightDialog';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Opens the highlight dialog.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function openHighlightDialog() {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
dispatch(openSheet(HighlightDialog));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the notification suggesting to start the recording.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function showStartRecordingNotification() {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
const openDialogCallback = () => navigate(screen.conference.recording);
|
||||
|
||||
dispatch(showStartRecordingNotificationWithCallback(openDialogCallback));
|
||||
};
|
||||
}
|
||||
70
react/features/recording/actions.web.tsx
Normal file
70
react/features/recording/actions.web.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { batch } from 'react-redux';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
import { hideDialog, openDialog } from '../base/dialog/actions';
|
||||
import {
|
||||
setAudioMuted,
|
||||
setAudioUnmutePermissions,
|
||||
setVideoMuted,
|
||||
setVideoUnmutePermissions
|
||||
} from '../base/media/actions';
|
||||
import { VIDEO_MUTISM_AUTHORITY } from '../base/media/constants';
|
||||
|
||||
import { showStartRecordingNotificationWithCallback } from './actions.any';
|
||||
import { RecordingTranscriptionDialog } from './components/Recording';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Grants recording consent by setting audio and video unmute permissions.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function grantRecordingConsent() {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
batch(() => {
|
||||
dispatch(setAudioUnmutePermissions(false, true));
|
||||
dispatch(setVideoUnmutePermissions(false, true));
|
||||
dispatch(hideDialog());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants recording consent, unmutes audio/video, and closes the dialog.
|
||||
* Restores the mute state that existed before the consent dialog was shown,
|
||||
* respecting the user's intentional mute choices from prejoin or initial settings.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function grantRecordingConsentAndUnmute() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
// Read the prejoin mute state from the dialog props stored in Redux
|
||||
const dialogProps = getState()['features/base/dialog'].componentProps as any;
|
||||
const audioMuted = dialogProps?.audioWasMuted ?? false;
|
||||
const videoMuted = dialogProps?.videoWasMuted ?? false;
|
||||
|
||||
batch(() => {
|
||||
dispatch(setAudioUnmutePermissions(false, true));
|
||||
dispatch(setVideoUnmutePermissions(false, true));
|
||||
|
||||
// Restore to the mute state before consent was requested.
|
||||
dispatch(setAudioMuted(audioMuted, false));
|
||||
dispatch(setVideoMuted(videoMuted, VIDEO_MUTISM_AUTHORITY.USER, false));
|
||||
dispatch(hideDialog());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the notification suggesting to start the recording.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function showStartRecordingNotification() {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
const openDialogCallback = () => dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
|
||||
|
||||
dispatch(showStartRecordingNotificationWithCallback(openDialogCallback));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet';
|
||||
import { isRecorderTranscriptionsRunning } from '../../transcribing/functions';
|
||||
import {
|
||||
getSessionStatusToShow,
|
||||
isLiveStreamingRunning,
|
||||
isRecordingRunning,
|
||||
isRemoteParticipantRecordingLocally
|
||||
} from '../functions';
|
||||
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Whether this is the Jibri recorder participant.
|
||||
*/
|
||||
_iAmRecorder: boolean;
|
||||
|
||||
|
||||
/**
|
||||
* Whether this meeting is being transcribed.
|
||||
*/
|
||||
_isTranscribing: boolean;
|
||||
|
||||
/**
|
||||
* Whether the recording/livestreaming/transcriber is currently running.
|
||||
*/
|
||||
_isVisible: boolean;
|
||||
|
||||
/**
|
||||
* The status of the higher priority session.
|
||||
*/
|
||||
_status?: string;
|
||||
|
||||
/**
|
||||
* The recording mode this indicator should display.
|
||||
*/
|
||||
mode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class for the {@code RecordingLabel} component.
|
||||
*/
|
||||
export default class AbstractRecordingLabel<P extends IProps = IProps> extends Component<P> {
|
||||
/**
|
||||
* Implements React {@code Component}'s render.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { _iAmRecorder, _isVisible } = this.props;
|
||||
|
||||
return _isVisible && !_iAmRecorder ? this._renderLabel() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the platform specific label component.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Element}
|
||||
*/
|
||||
_renderLabel(): React.ReactNode | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated
|
||||
* {@code AbstractRecordingLabel}'s props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {IProps} ownProps - The component's own props.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _status: ?string
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
const { mode } = ownProps;
|
||||
const isLiveStreamingLabel = mode === JitsiRecordingConstants.mode.STREAM;
|
||||
const _isTranscribing = isRecorderTranscriptionsRunning(state);
|
||||
const _isLivestreamingRunning = isLiveStreamingRunning(state);
|
||||
const _isVisible = isLiveStreamingLabel
|
||||
? _isLivestreamingRunning // this is the livestreaming label
|
||||
: isRecordingRunning(state) || isRemoteParticipantRecordingLocally(state); // this is the recording label
|
||||
|
||||
return {
|
||||
_isVisible,
|
||||
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
|
||||
_isTranscribing,
|
||||
_status: getSessionStatusToShow(state, mode)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { IconSites } from '../../../base/icons/svg';
|
||||
import { MEET_FEATURES } from '../../../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../../../base/jwt/functions';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
|
||||
import { isCloudRecordingRunning, isLiveStreamingButtonVisible, isLiveStreamingRunning } from '../../functions';
|
||||
|
||||
import { getLiveStreaming } from './functions';
|
||||
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link AbstractLiveStreamButton}.
|
||||
*/
|
||||
export interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* True if the button needs to be disabled.
|
||||
*/
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* True if there is a running active live stream, false otherwise.
|
||||
*/
|
||||
_isLiveStreamRunning: boolean;
|
||||
|
||||
/**
|
||||
* The tooltip to display when hovering over the button.
|
||||
*/
|
||||
_tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An abstract class of a button for starting and stopping live streaming.
|
||||
*/
|
||||
export default class AbstractLiveStreamButton<P extends IProps> extends AbstractButton<P> {
|
||||
override accessibilityLabel = 'dialog.startLiveStreaming';
|
||||
override toggledAccessibilityLabel = 'dialog.stopLiveStreaming';
|
||||
override icon = IconSites;
|
||||
override label = 'dialog.startLiveStreaming';
|
||||
override toggledLabel = 'dialog.stopLiveStreaming';
|
||||
|
||||
/**
|
||||
* Returns the tooltip that should be displayed when the button is disabled.
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
override _getTooltip() {
|
||||
return this.props._tooltip ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to be implemented by subclasses, which should be used
|
||||
* to handle the live stream button being clicked / pressed.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onHandleClick() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
|
||||
|
||||
if (!dialogShown) {
|
||||
this._onHandleClick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a boolean value indicating if this button is disabled or not.
|
||||
*
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isDisabled() {
|
||||
return this.props._disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in toggled state or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return this.props._isLiveStreamRunning;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code AbstractLiveStreamButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {IProps} ownProps - The own props of the Component.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _disabled: boolean,
|
||||
* _isLiveStreamRunning: boolean,
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
|
||||
let { visible } = ownProps;
|
||||
|
||||
// A button can be disabled/enabled only if enableFeaturesBasedOnToken
|
||||
// is on or if the recording is running.
|
||||
let _disabled = false;
|
||||
let _tooltip = '';
|
||||
|
||||
if (typeof visible === 'undefined') {
|
||||
// If the containing component provides the visible prop, that is one
|
||||
// above all, but if not, the button should be autonomous and decide on
|
||||
// its own to be visible or not.
|
||||
const liveStreaming = getLiveStreaming(state);
|
||||
|
||||
visible = isLiveStreamingButtonVisible({
|
||||
liveStreamingAllowed: isJwtFeatureEnabled(state, MEET_FEATURES.LIVESTREAMING, false),
|
||||
liveStreamingEnabled: liveStreaming?.enabled,
|
||||
isInBreakoutRoom: isInBreakoutRoom(state)
|
||||
});
|
||||
}
|
||||
|
||||
// disable the button if cloud recording is running.
|
||||
if (visible && isCloudRecordingRunning(state)) {
|
||||
_disabled = true;
|
||||
_tooltip = 'dialog.liveStreamingDisabledBecauseOfActiveRecordingTooltip';
|
||||
}
|
||||
|
||||
// disable the button if we are in a breakout room.
|
||||
if (isInBreakoutRoom(state)) {
|
||||
_disabled = true;
|
||||
}
|
||||
|
||||
return {
|
||||
_disabled,
|
||||
_isLiveStreamRunning: isLiveStreamingRunning(state),
|
||||
_tooltip,
|
||||
visible
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { createLiveStreamingDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link AbstractStartLiveStreamDialog}.
|
||||
*/
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The {@code JitsiConference} for the current conference.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* The current state of interactions with the Google API. Determines what
|
||||
* Google related UI should display.
|
||||
*/
|
||||
_googleAPIState: number;
|
||||
|
||||
/**
|
||||
* The email of the user currently logged in to the Google web client
|
||||
* application.
|
||||
*/
|
||||
_googleProfileEmail: string;
|
||||
|
||||
/**
|
||||
* The live stream key that was used before.
|
||||
*/
|
||||
_streamKey?: string;
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
navigation?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of
|
||||
* {@link AbstractStartLiveStreamDialog}.
|
||||
*/
|
||||
export interface IState {
|
||||
|
||||
/**
|
||||
* Details about the broadcasts available for use for the logged in Google
|
||||
* user's YouTube account.
|
||||
*/
|
||||
broadcasts?: Array<any>;
|
||||
|
||||
/**
|
||||
* The error type, as provided by Google, for the most recent error
|
||||
* encountered by the Google API.
|
||||
*/
|
||||
errorType?: string;
|
||||
|
||||
/**
|
||||
* The boundStreamID of the broadcast currently selected in the broadcast
|
||||
* dropdown.
|
||||
*/
|
||||
selectedBoundStreamID?: string;
|
||||
|
||||
/**
|
||||
* The selected or entered stream key to use for YouTube live streaming.
|
||||
*/
|
||||
streamKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements an abstract class for the StartLiveStreamDialog on both platforms.
|
||||
*
|
||||
* NOTE: Google log-in is not supported for mobile yet for later implementation
|
||||
* but the abstraction of its properties are already present in this abstract
|
||||
* class.
|
||||
*/
|
||||
export default class AbstractStartLiveStreamDialog<P extends IProps>
|
||||
extends Component<P, IState> {
|
||||
_isMounted: boolean;
|
||||
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: P) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
broadcasts: undefined,
|
||||
errorType: undefined,
|
||||
selectedBoundStreamID: undefined,
|
||||
streamKey: ''
|
||||
};
|
||||
|
||||
/**
|
||||
* Instance variable used to flag whether the component is or is not
|
||||
* mounted. Used as a hack to avoid setting state on an unmounted
|
||||
* component.
|
||||
*
|
||||
* @private
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._isMounted = false;
|
||||
|
||||
this._onCancel = this._onCancel.bind(this);
|
||||
this._onStreamKeyChange = this._onStreamKeyChange.bind(this);
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link Component#componentDidMount()}. Invoked immediately
|
||||
* after this component is mounted.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentWillUnmount()}. Invoked
|
||||
* immediately before this component is unmounted and destroyed.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the passed in {@link onCancel} callback and closes
|
||||
* {@code StartLiveStreamDialog}.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} True is returned to close the modal.
|
||||
*/
|
||||
_onCancel() {
|
||||
sendAnalytics(createLiveStreamingDialogEvent('start', 'cancel.button'));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user to sign in, if not already signed in, and then requests a
|
||||
* list of the user's YouTube broadcasts.
|
||||
*
|
||||
* NOTE: To be implemented by platforms.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_onGetYouTubeBroadcasts(): Promise<any> | void {
|
||||
// to be overwritten by child classes.
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to update the {@code StartLiveStreamDialog} component's
|
||||
* display of the entered YouTube stream key.
|
||||
*
|
||||
* @param {string} streamKey - The stream key entered in the field.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStreamKeyChange(streamKey: string) {
|
||||
this._setStateIfMounted({
|
||||
streamKey,
|
||||
selectedBoundStreamID: undefined
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the passed in {@link onSubmit} callback with the entered stream
|
||||
* key, and then closes {@code StartLiveStreamDialog}.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} False if no stream key is entered to preventing
|
||||
* closing, true to close the modal.
|
||||
*/
|
||||
_onSubmit() {
|
||||
const { broadcasts, selectedBoundStreamID } = this.state;
|
||||
const key
|
||||
= (this.state.streamKey || this.props._streamKey || '').trim();
|
||||
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let selectedBroadcastID = null;
|
||||
|
||||
if (selectedBoundStreamID) {
|
||||
const selectedBroadcast = broadcasts?.find(
|
||||
broadcast => broadcast.boundStreamID === selectedBoundStreamID);
|
||||
|
||||
selectedBroadcastID = selectedBroadcast?.id;
|
||||
}
|
||||
|
||||
sendAnalytics(
|
||||
createLiveStreamingDialogEvent('start', 'confirm.button'));
|
||||
|
||||
this.props._conference?.startRecording({
|
||||
broadcastId: selectedBroadcastID,
|
||||
mode: JitsiRecordingConstants.mode.STREAM,
|
||||
streamId: key
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the internal state if the component is still mounted. This is a
|
||||
* workaround for all the state setting that occurs after ajax.
|
||||
*
|
||||
* @param {Object} newState - The new state to merge into the existing
|
||||
* state.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setStateIfMounted(newState: IState) {
|
||||
if (this._isMounted) {
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the component's props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {{
|
||||
* _conference: Object,
|
||||
* _googleAPIState: number,
|
||||
* _googleProfileEmail: string,
|
||||
* _streamKey: string
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_conference: state['features/base/conference'].conference,
|
||||
_googleAPIState: state['features/google-api'].googleAPIState,
|
||||
_googleProfileEmail: state['features/google-api'].profileEmail,
|
||||
_streamKey: state['features/recording'].streamKey
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { createLiveStreamingDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { getActiveSession } from '../../functions';
|
||||
import { ISessionData } from '../../reducer';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link StopLiveStreamDialog}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The {@code JitsiConference} for the current conference.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* The redux representation of the live streaming to be stopped.
|
||||
*/
|
||||
_session?: ISessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React Component for confirming the participant wishes to stop the currently
|
||||
* active live stream of the conference.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
export default class AbstractStopLiveStreamDialog extends Component<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code StopLiveStreamDialog} instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when stopping of live streaming is confirmed.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} True to close the modal.
|
||||
*/
|
||||
_onSubmit() {
|
||||
sendAnalytics(createLiveStreamingDialogEvent('stop', 'confirm.button'));
|
||||
|
||||
const { _session } = this.props;
|
||||
|
||||
this.props._conference?.stopRecording(_session?.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the React {@code Component} props of
|
||||
* {@code StopLiveStreamDialog}.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _conference: Object,
|
||||
* _session: Object
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_conference: state['features/base/conference'].conference,
|
||||
_session: getActiveSession(state, JitsiRecordingConstants.mode.STREAM)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { type DebouncedFunc, debounce } from 'lodash-es';
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
|
||||
import { getLiveStreaming } from './functions';
|
||||
|
||||
|
||||
export type LiveStreaming = {
|
||||
|
||||
// Terms link
|
||||
dataPrivacyLink: string;
|
||||
enabled: boolean;
|
||||
helpLink: string;
|
||||
|
||||
// Documentation reference for the live streaming feature.
|
||||
termsLink: string; // Data privacy link
|
||||
validatorRegExpString: string; // RegExp string that validates the stream key input field
|
||||
};
|
||||
|
||||
export type LiveStreamingProps = {
|
||||
dataPrivacyURL?: string;
|
||||
enabled: boolean;
|
||||
helpURL?: string;
|
||||
streamLinkRegexp: RegExp;
|
||||
termsURL?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The props of the component.
|
||||
*/
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The live streaming dialog properties.
|
||||
*/
|
||||
_liveStreaming: LiveStreamingProps;
|
||||
|
||||
/**
|
||||
* Callback invoked when the entered stream key has changed.
|
||||
*/
|
||||
onChange: Function;
|
||||
|
||||
/**
|
||||
* The stream key value to display as having been entered so far.
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of the component.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* Whether or not to show the warnings that the passed in value seems like
|
||||
* an improperly formatted stream key.
|
||||
*/
|
||||
showValidationError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An abstract React Component for entering a key for starting a YouTube live
|
||||
* stream.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
export default class AbstractStreamKeyForm<P extends IProps>
|
||||
extends Component<P, IState> {
|
||||
|
||||
_debouncedUpdateValidationErrorVisibility: DebouncedFunc<() => void>;
|
||||
|
||||
/**
|
||||
* Constructor for the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: P) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showValidationError: Boolean(this.props.value)
|
||||
&& !this._validateStreamKey(this.props.value)
|
||||
};
|
||||
|
||||
this._debouncedUpdateValidationErrorVisibility = debounce(
|
||||
this._updateValidationErrorVisibility.bind(this),
|
||||
800,
|
||||
{ leading: false }
|
||||
);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onInputChange = this._onInputChange.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React Component's componentDidUpdate.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidUpdate(prevProps: P) {
|
||||
if (this.props.value !== prevProps.value) {
|
||||
this._debouncedUpdateValidationErrorVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React Component's componentWillUnmount.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentWillUnmount() {
|
||||
this._debouncedUpdateValidationErrorVisibility.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when the value of the input field has updated through
|
||||
* user input. This forwards the value (string only, even if it was a dom
|
||||
* event) to the onChange prop provided to the component.
|
||||
*
|
||||
* @param {Object | string} change - DOM Event for value change or the
|
||||
* changed text.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onInputChange(change: any) {
|
||||
const value = typeof change === 'object' ? change.target.value : change;
|
||||
|
||||
this.props.onChange(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the stream key value seems like a valid stream key and sets the
|
||||
* state for showing or hiding the notification about the stream key seeming
|
||||
* invalid.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_updateValidationErrorVisibility() {
|
||||
const newShowValidationError = Boolean(this.props.value)
|
||||
&& !this._validateStreamKey(this.props.value);
|
||||
|
||||
if (newShowValidationError !== this.state.showValidationError) {
|
||||
this.setState({
|
||||
showValidationError: newShowValidationError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a passed in stream key appears to be in a valid format.
|
||||
*
|
||||
* @param {string} streamKey - The stream key to check for valid formatting.
|
||||
* @returns {void}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateStreamKey(streamKey = '') {
|
||||
const trimmedKey = streamKey.trim();
|
||||
const match = this.props._liveStreaming.streamLinkRegexp.exec(trimmedKey);
|
||||
|
||||
return Boolean(match);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the component's props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {{
|
||||
* _liveStreaming: LiveStreamingProps
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_liveStreaming: getLiveStreaming(state)
|
||||
};
|
||||
}
|
||||
26
react/features/recording/components/LiveStream/constants.ts
Normal file
26
react/features/recording/components/LiveStream/constants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The URL for Google Privacy Policy.
|
||||
*/
|
||||
export const GOOGLE_PRIVACY_POLICY = 'https://policies.google.com/privacy';
|
||||
|
||||
/**
|
||||
* The URL that is the main landing page for YouTube live streaming and should
|
||||
* have a user's live stream key.
|
||||
*/
|
||||
export const YOUTUBE_LIVE_DASHBOARD_URL = 'https://www.youtube.com/live_dashboard';
|
||||
|
||||
/**
|
||||
* The URL for YouTube terms and conditions.
|
||||
*/
|
||||
export const YOUTUBE_TERMS_URL = 'https://www.youtube.com/t/terms';
|
||||
|
||||
/**
|
||||
* The live streaming help link to display.
|
||||
*/
|
||||
export const JITSI_LIVE_STREAMING_HELP_LINK = 'https://jitsi.org/live';
|
||||
|
||||
/**
|
||||
* The YouTube stream link RegExp.
|
||||
*/
|
||||
export const FOUR_GROUPS_DASH_SEPARATED = /^(?:[a-zA-Z0-9]{4}(?:-(?!$)|$)){4}/;
|
||||
|
||||
28
react/features/recording/components/LiveStream/functions.ts
Normal file
28
react/features/recording/components/LiveStream/functions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { sanitizeUrl } from '../../../base/util/uri';
|
||||
|
||||
import {
|
||||
FOUR_GROUPS_DASH_SEPARATED,
|
||||
GOOGLE_PRIVACY_POLICY,
|
||||
JITSI_LIVE_STREAMING_HELP_LINK,
|
||||
YOUTUBE_TERMS_URL
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* Get the live streaming options.
|
||||
*
|
||||
* @param {Object} state - The global state.
|
||||
* @returns {LiveStreaming}
|
||||
*/
|
||||
export function getLiveStreaming(state: IReduxState) {
|
||||
const { liveStreaming = {} } = state['features/base/config'];
|
||||
const regexp = liveStreaming.validatorRegExpString && new RegExp(liveStreaming.validatorRegExpString);
|
||||
|
||||
return {
|
||||
enabled: Boolean(liveStreaming.enabled),
|
||||
helpURL: sanitizeUrl(liveStreaming.helpLink || JITSI_LIVE_STREAMING_HELP_LINK)?.toString(),
|
||||
termsURL: sanitizeUrl(liveStreaming.termsLink || YOUTUBE_TERMS_URL)?.toString(),
|
||||
dataPrivacyURL: sanitizeUrl(liveStreaming.dataPrivacyLink || GOOGLE_PRIVACY_POLICY)?.toString(),
|
||||
streamLinkRegexp: regexp || FOUR_GROUPS_DASH_SEPARATED
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { Text, View, ViewStyle } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../../app/types';
|
||||
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { setGoogleAPIState } from '../../../../google-api/actions';
|
||||
import GoogleSignInButton from '../../../../google-api/components/GoogleSignInButton.native';
|
||||
import {
|
||||
GOOGLE_API_STATES,
|
||||
GOOGLE_SCOPE_YOUTUBE
|
||||
} from '../../../../google-api/constants';
|
||||
// eslint-disable-next-line lines-around-comment
|
||||
// @ts-ignore
|
||||
import googleApi from '../../../../google-api/googleApi.native';
|
||||
import logger from '../../../logger';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* Prop type of the component {@code GoogleSigninForm}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Style of the dialogs feature.
|
||||
*/
|
||||
_dialogStyles: any;
|
||||
|
||||
/**
|
||||
* The Redux dispatch Function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* The current state of the Google api as defined in {@code constants.js}.
|
||||
*/
|
||||
googleAPIState: number;
|
||||
|
||||
/**
|
||||
* The recently received Google response.
|
||||
*/
|
||||
googleResponse: any;
|
||||
|
||||
/**
|
||||
* A callback to be invoked when an authenticated user changes, so
|
||||
* then we can get (or clear) the YouTube stream key.
|
||||
*/
|
||||
onUserChanged: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to render a google sign in form, or a google stream picker dialog.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class GoogleSigninForm extends Component<IProps> {
|
||||
/**
|
||||
* Instantiates a new {@code GoogleSigninForm} component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._logGoogleError = this._logGoogleError.bind(this);
|
||||
this._onGoogleButtonPress = this._onGoogleButtonPress.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's Component.componentDidMount.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidMount() {
|
||||
googleApi.hasPlayServices()
|
||||
.then(() => {
|
||||
googleApi.configure({
|
||||
offlineAccess: false,
|
||||
scopes: [ GOOGLE_SCOPE_YOUTUBE ]
|
||||
});
|
||||
|
||||
googleApi.signInSilently().then((response: any) => {
|
||||
this._setApiState(response
|
||||
? GOOGLE_API_STATES.SIGNED_IN
|
||||
: GOOGLE_API_STATES.LOADED,
|
||||
response);
|
||||
}, () => {
|
||||
this._setApiState(GOOGLE_API_STATES.LOADED);
|
||||
});
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
this._logGoogleError(error);
|
||||
this._setApiState(GOOGLE_API_STATES.NOT_AVAILABLE);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { _dialogStyles, t } = this.props;
|
||||
const { googleAPIState, googleResponse } = this.props;
|
||||
const signedInUser = googleResponse?.user?.email;
|
||||
|
||||
if (googleAPIState === GOOGLE_API_STATES.NOT_AVAILABLE
|
||||
|| googleAPIState === GOOGLE_API_STATES.NEEDS_LOADING
|
||||
|| typeof googleAPIState === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userInfo = signedInUser
|
||||
? `${t('liveStreaming.signedInAs')} ${signedInUser}`
|
||||
: t('liveStreaming.signInCTA');
|
||||
|
||||
return (
|
||||
<View style = { styles.formWrapper as ViewStyle }>
|
||||
<View style = { styles.helpText as ViewStyle }>
|
||||
<Text
|
||||
style = { [
|
||||
_dialogStyles.text,
|
||||
styles.text
|
||||
] }>
|
||||
{ userInfo }
|
||||
</Text>
|
||||
</View>
|
||||
<GoogleSignInButton
|
||||
onClick = { this._onGoogleButtonPress }
|
||||
signedIn = {
|
||||
googleAPIState === GOOGLE_API_STATES.SIGNED_IN } />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to log developer related errors.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} error - The error to be logged.
|
||||
* @returns {void}
|
||||
*/
|
||||
_logGoogleError(error: Error) {
|
||||
// NOTE: This is a developer error message, not intended for the
|
||||
// user to see.
|
||||
logger.error('Google API error. Possible cause: bad config.', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to be invoked when the user presses the Google button,
|
||||
* regardless of being logged in or out.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onGoogleButtonPress() {
|
||||
const { googleResponse } = this.props;
|
||||
|
||||
if (googleResponse?.user) {
|
||||
// the user is signed in
|
||||
this._onSignOut();
|
||||
} else {
|
||||
this._onSignIn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a sign in if the user is not signed in yet.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSignIn() {
|
||||
googleApi.signIn().then((response: any) => {
|
||||
this._setApiState(GOOGLE_API_STATES.SIGNED_IN, response);
|
||||
}, this._logGoogleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a sign out if the user is signed in.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSignOut() {
|
||||
googleApi.signOut().then((response: any) => {
|
||||
this._setApiState(GOOGLE_API_STATES.LOADED, response);
|
||||
}, this._logGoogleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the API (Google Auth) state.
|
||||
*
|
||||
* @private
|
||||
* @param {number} apiState - The state of the API.
|
||||
* @param {?Object} googleResponse - The response from the API.
|
||||
* @returns {void}
|
||||
*/
|
||||
_setApiState(apiState: number, googleResponse?: Object) {
|
||||
this.props.onUserChanged(googleResponse);
|
||||
this.props.dispatch(setGoogleAPIState(apiState, googleResponse));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code GoogleSigninForm} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* googleAPIState: number,
|
||||
* googleResponse: Object
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const { googleAPIState, googleResponse } = state['features/google-api'];
|
||||
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
googleAPIState,
|
||||
googleResponse
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(GoogleSigninForm));
|
||||
@@ -0,0 +1,63 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import { LIVE_STREAMING_ENABLED } from '../../../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../../../base/flags/functions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { navigate }
|
||||
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../../mobile/navigation/routes';
|
||||
import AbstractLiveStreamButton, {
|
||||
IProps as AbstractProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractLiveStreamButton';
|
||||
import { IProps } from '../AbstractStartLiveStreamDialog';
|
||||
|
||||
import StopLiveStreamDialog from './StopLiveStreamDialog';
|
||||
|
||||
type Props = IProps & AbstractProps;
|
||||
|
||||
/**
|
||||
* Button for opening the live stream settings screen.
|
||||
*/
|
||||
class LiveStreamButton extends AbstractLiveStreamButton<Props> {
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onHandleClick() {
|
||||
const { _isLiveStreamRunning, dispatch } = this.props;
|
||||
|
||||
if (_isLiveStreamRunning) {
|
||||
dispatch(openDialog('StopLiveStreamDialog', StopLiveStreamDialog));
|
||||
} else {
|
||||
navigate(screen.conference.liveStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for this component.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} ownProps - The properties explicitly passed to the component
|
||||
* instance.
|
||||
* @private
|
||||
* @returns {Props}
|
||||
*/
|
||||
export function mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
const enabled = getFeatureFlag(state, LIVE_STREAMING_ENABLED, true);
|
||||
const abstractProps = _abstractMapStateToProps(state, ownProps);
|
||||
|
||||
return {
|
||||
...abstractProps,
|
||||
visible: enabled && abstractProps.visible
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(LiveStreamButton));
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
|
||||
import { StyleType } from '../../../../base/styles/functions.any';
|
||||
// eslint-disable-next-line lines-around-comment
|
||||
// @ts-ignore
|
||||
import googleApi from '../../../../google-api/googleApi.native';
|
||||
import HeaderNavigationButton
|
||||
from '../../../../mobile/navigation/components/HeaderNavigationButton';
|
||||
import { goBack }
|
||||
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { setLiveStreamKey } from '../../../actions';
|
||||
import AbstractStartLiveStreamDialog, { IProps, _mapStateToProps } from '../AbstractStartLiveStreamDialog';
|
||||
|
||||
import GoogleSigninForm from './GoogleSigninForm';
|
||||
import StreamKeyForm from './StreamKeyForm';
|
||||
import StreamKeyPicker from './StreamKeyPicker';
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* A React Component for requesting a YouTube stream key to use for live
|
||||
* streaming of the current conference.
|
||||
*/
|
||||
class StartLiveStreamDialog extends AbstractStartLiveStreamDialog<IProps> {
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onStartPress = this._onStartPress.bind(this);
|
||||
this._onStreamKeyChangeNative
|
||||
= this._onStreamKeyChangeNative.bind(this);
|
||||
this._onStreamKeyPick = this._onStreamKeyPick.bind(this);
|
||||
this._onUserChanged = this._onUserChanged.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}. Invoked
|
||||
* immediately after this component is mounted.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
const { navigation, t } = this.props;
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<HeaderNavigationButton
|
||||
label = { t('dialog.start') }
|
||||
onPress = { this._onStartPress }
|
||||
twoActions = { true } />
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts live stream session and goes back to the previous screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartPress() {
|
||||
this._onSubmit() && goBack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component}'s render.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<JitsiScreen style = { styles.startLiveStreamContainer as StyleType }>
|
||||
<GoogleSigninForm
|
||||
onUserChanged = { this._onUserChanged } />
|
||||
<StreamKeyPicker
|
||||
broadcasts = { this.state.broadcasts }
|
||||
onChange = { this._onStreamKeyPick } />
|
||||
<StreamKeyForm
|
||||
onChange = { this._onStreamKeyChangeNative }
|
||||
value = {
|
||||
this.state.streamKey || this.props._streamKey || ''
|
||||
} />
|
||||
</JitsiScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle stream key changes.
|
||||
*
|
||||
* FIXME: This is a temporary method to store the streaming key on mobile
|
||||
* for easier use, until the Google sign-in is implemented. We don't store
|
||||
* the key on web for security reasons (e.g. We don't want to have the key
|
||||
* stored if the used signed out).
|
||||
*
|
||||
* @private
|
||||
* @param {string} streamKey - The new key value.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStreamKeyChangeNative(streamKey: string) {
|
||||
this.props.dispatch(setLiveStreamKey(streamKey));
|
||||
this._onStreamKeyChange(streamKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to be invoked when the user selects a stream from the picker.
|
||||
*
|
||||
* @private
|
||||
* @param {string} streamKey - The key of the selected stream.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStreamKeyPick(streamKey: string) {
|
||||
this.setState({
|
||||
streamKey
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback to be invoked when an authenticated user changes, so
|
||||
* then we can get (or clear) the YouTube stream key.
|
||||
*
|
||||
* TODO: Handle errors by showing some indication to the user.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} response - The retrieved signin response.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onUserChanged(response: Object) {
|
||||
if (response) {
|
||||
googleApi.getTokens()
|
||||
.then((tokens: any) => {
|
||||
googleApi.getYouTubeLiveStreams(tokens.accessToken)
|
||||
.then((broadcasts: any) => {
|
||||
this.setState({
|
||||
broadcasts
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
broadcasts: undefined,
|
||||
streamKey: undefined
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
broadcasts: undefined,
|
||||
streamKey: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDialog';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import AbstractStopLiveStreamDialog, {
|
||||
_mapStateToProps
|
||||
} from '../AbstractStopLiveStreamDialog';
|
||||
|
||||
/**
|
||||
* A React Component for confirming the participant wishes to stop the currently
|
||||
* active live stream of the conference.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StopLiveStreamDialog extends AbstractStopLiveStreamDialog {
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
descriptionKey = 'dialog.stopStreamingWarning'
|
||||
onSubmit = { this._onSubmit } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { Linking, Text, View, ViewStyle } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Button from '../../../../base/ui/components/native/Button';
|
||||
import Input from '../../../../base/ui/components/native/Input';
|
||||
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
|
||||
import AbstractStreamKeyForm, {
|
||||
IProps as AbstractProps
|
||||
} from '../AbstractStreamKeyForm';
|
||||
import { getLiveStreaming } from '../functions';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* Style of the dialogs feature.
|
||||
*/
|
||||
_dialogStyles: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React Component for entering a key for starting a YouTube live stream.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code StreamKeyForm} instance.
|
||||
*
|
||||
* @param {IProps} props - The React {@code Component} props to initialize
|
||||
* the new {@code StreamKeyForm} instance with.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onOpenGooglePrivacyPolicy = this._onOpenGooglePrivacyPolicy.bind(this);
|
||||
this._onOpenHelp = this._onOpenHelp.bind(this);
|
||||
this._onOpenYoutubeTerms = this._onOpenYoutubeTerms.bind(this);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { _dialogStyles, t } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style = { styles.formWrapper as ViewStyle }>
|
||||
<Input
|
||||
customStyles = {{
|
||||
input: styles.streamKeyInput,
|
||||
container: styles.streamKeyContainer }}
|
||||
onChange = { this._onInputChange }
|
||||
placeholder = { t('liveStreaming.enterStreamKey') }
|
||||
value = { this.props.value } />
|
||||
<View style = { styles.formValidationItem as ViewStyle }>
|
||||
{
|
||||
this.state.showValidationError && <Text
|
||||
style = { [
|
||||
_dialogStyles.text,
|
||||
styles.warningText
|
||||
] }>
|
||||
{ t('liveStreaming.invalidStreamKey') }
|
||||
</Text>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
<View style = { styles.formButtonsWrapper as ViewStyle }>
|
||||
<Button
|
||||
accessibilityLabel = 'liveStreaming.streamIdHelp'
|
||||
labelKey = 'liveStreaming.streamIdHelp'
|
||||
labelStyle = { styles.buttonLabelStyle }
|
||||
onClick = { this._onOpenHelp }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
<Button
|
||||
accessibilityLabel = 'liveStreaming.youtubeTerms'
|
||||
labelKey = 'liveStreaming.youtubeTerms'
|
||||
labelStyle = { styles.buttonLabelStyle }
|
||||
onClick = { this._onOpenYoutubeTerms }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
<Button
|
||||
accessibilityLabel = 'liveStreaming.googlePrivacyPolicy'
|
||||
labelKey = 'liveStreaming.googlePrivacyPolicy'
|
||||
labelStyle = { styles.buttonLabelStyle }
|
||||
onClick = { this._onOpenGooglePrivacyPolicy }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Google Privacy Policy web page.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOpenGooglePrivacyPolicy() {
|
||||
const url = this.props._liveStreaming.dataPrivacyURL;
|
||||
|
||||
if (typeof url === 'string') {
|
||||
Linking.openURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the information link on how to manually locate a YouTube broadcast
|
||||
* stream key.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOpenHelp() {
|
||||
const url = this.props._liveStreaming.helpURL;
|
||||
|
||||
if (typeof url === 'string') {
|
||||
Linking.openURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the YouTube terms and conditions web page.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOpenYoutubeTerms() {
|
||||
const url = this.props._liveStreaming.termsURL;
|
||||
|
||||
if (typeof url === 'string') {
|
||||
Linking.openURL(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code StreamKeyForm} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _liveStreaming: LiveStreamingProps
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
_liveStreaming: getLiveStreaming(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StreamKeyForm));
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import {
|
||||
Linking,
|
||||
Text,
|
||||
TouchableHighlight,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { YOUTUBE_LIVE_DASHBOARD_URL } from '../constants';
|
||||
|
||||
import styles, { ACTIVE_OPACITY, TOUCHABLE_UNDERLAY } from './styles';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Style of the dialogs feature.
|
||||
*/
|
||||
_dialogStyles: any;
|
||||
|
||||
/**
|
||||
* The list of broadcasts the user can pick from.
|
||||
*/
|
||||
broadcasts?: Array<{ key: string; title: string; }>;
|
||||
|
||||
/**
|
||||
* Callback to be invoked when the user picked a broadcast. To be invoked
|
||||
* with a single key (string).
|
||||
*/
|
||||
onChange: Function;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* The key of the currently selected stream.
|
||||
*/
|
||||
streamKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to implement a stream key picker (dropdown) component to allow the user
|
||||
* to choose from the available Google Broadcasts/Streams.
|
||||
*
|
||||
* NOTE: This component is currently only used on mobile, but it is advised at
|
||||
* a later point to unify mobile and web logic for this functionality. But it's
|
||||
* out of the scope for now of the mobile live streaming functionality.
|
||||
*/
|
||||
class StreamKeyPicker extends Component<IProps, IState> {
|
||||
|
||||
/**
|
||||
* Instantiates a new instance of StreamKeyPicker.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
streamKey: null
|
||||
};
|
||||
|
||||
this._onOpenYoutubeDashboard = this._onOpenYoutubeDashboard.bind(this);
|
||||
this._onStreamPick = this._onStreamPick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { _dialogStyles, broadcasts } = this.props;
|
||||
|
||||
if (!broadcasts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!broadcasts.length) {
|
||||
return (
|
||||
<View style = { styles.formWrapper as ViewStyle }>
|
||||
<TouchableOpacity
|
||||
onPress = { this._onOpenYoutubeDashboard }>
|
||||
<Text
|
||||
style = { [
|
||||
_dialogStyles.text,
|
||||
styles.warningText
|
||||
] }>
|
||||
{ this.props.t(
|
||||
'liveStreaming.getStreamKeyManually') }
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style = { styles.formWrapper as ViewStyle }>
|
||||
<View style = { styles.streamKeyPickerCta as ViewStyle }>
|
||||
<Text
|
||||
style = { [
|
||||
_dialogStyles.text,
|
||||
styles.text
|
||||
] }>
|
||||
{ this.props.t('liveStreaming.choose') }
|
||||
</Text>
|
||||
</View>
|
||||
<View style = { styles.streamKeyPickerWrapper as ViewStyle } >
|
||||
{ broadcasts.map((broadcast, index) =>
|
||||
(<TouchableHighlight
|
||||
activeOpacity = { ACTIVE_OPACITY }
|
||||
key = { index }
|
||||
onPress = { this._onStreamPick(broadcast.key) }
|
||||
style = { [
|
||||
styles.streamKeyPickerItem,
|
||||
this.state.streamKey === broadcast.key
|
||||
? styles.streamKeyPickerItemHighlight : null
|
||||
] as ViewStyle[] }
|
||||
underlayColor = { TOUCHABLE_UNDERLAY }>
|
||||
<Text
|
||||
style = { [
|
||||
_dialogStyles.text,
|
||||
styles.text
|
||||
] }>
|
||||
{ broadcast.title }
|
||||
</Text>
|
||||
</TouchableHighlight>))
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the link which should display the YouTube broadcast live stream
|
||||
* key.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOpenYoutubeDashboard() {
|
||||
Linking.openURL(YOUTUBE_LIVE_DASHBOARD_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to be invoked when the user picks a stream from the list.
|
||||
*
|
||||
* @private
|
||||
* @param {string} streamKey - The key of the stream selected.
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onStreamPick(streamKey: string) {
|
||||
return () => {
|
||||
this.setState({
|
||||
streamKey
|
||||
});
|
||||
this.props.onChange(streamKey);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(
|
||||
connect(_abstractMapStateToProps)(StreamKeyPicker));
|
||||
146
react/features/recording/components/LiveStream/native/styles.ts
Normal file
146
react/features/recording/components/LiveStream/native/styles.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { createStyleSheet } from '../../../../base/styles/functions.native';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
/**
|
||||
* Opacity of the TouchableHighlight.
|
||||
*/
|
||||
export const ACTIVE_OPACITY = 0.3;
|
||||
|
||||
/**
|
||||
* Underlay of the TouchableHighlight.
|
||||
*/
|
||||
export const TOUCHABLE_UNDERLAY = BaseTheme.palette.ui06;
|
||||
|
||||
/**
|
||||
* The styles of the React {@code Components} of LiveStream.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
|
||||
/**
|
||||
* Generic component to wrap form sections into achieving a unified look.
|
||||
*/
|
||||
formWrapper: {
|
||||
alignItems: 'stretch',
|
||||
flexDirection: 'column',
|
||||
paddingHorizontal: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
formValidationItem: {
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
height: BaseTheme.spacing[4],
|
||||
marginTop: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
formButtonsWrapper: {
|
||||
alignSelf: 'center',
|
||||
display: 'flex',
|
||||
maxWidth: 200
|
||||
},
|
||||
|
||||
buttonLabelStyle: {
|
||||
color: BaseTheme.palette.link01
|
||||
},
|
||||
|
||||
/**
|
||||
* Explaining text on the top of the sign in form.
|
||||
*/
|
||||
helpText: {
|
||||
marginBottom: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
/**
|
||||
* Container for the live stream screen.
|
||||
*/
|
||||
startLiveStreamContainer: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: BaseTheme.spacing[2],
|
||||
paddingVertical: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper link text.
|
||||
*/
|
||||
streamKeyHelp: {
|
||||
alignSelf: 'flex-end'
|
||||
},
|
||||
|
||||
/**
|
||||
* Input field to manually enter stream key.
|
||||
*/
|
||||
streamKeyInput: {
|
||||
alignSelf: 'stretch',
|
||||
color: BaseTheme.palette.text01,
|
||||
textAlign: 'left'
|
||||
},
|
||||
|
||||
streamKeyContainer: {
|
||||
marginTop: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
/**
|
||||
* Custom component to pick a broadcast from the list fetched from Google.
|
||||
*/
|
||||
streamKeyPicker: {
|
||||
alignSelf: 'stretch',
|
||||
flex: 1,
|
||||
height: 40,
|
||||
marginHorizontal: BaseTheme.spacing[1],
|
||||
width: 300
|
||||
},
|
||||
|
||||
/**
|
||||
* CTA (label) of the picker.
|
||||
*/
|
||||
streamKeyPickerCta: {
|
||||
marginBottom: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
/**
|
||||
* Style of a single item in the list.
|
||||
*/
|
||||
streamKeyPickerItem: {
|
||||
padding: BaseTheme.spacing[1]
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the selected item.
|
||||
*/
|
||||
streamKeyPickerItemHighlight: {
|
||||
backgroundColor: BaseTheme.palette.ui04
|
||||
},
|
||||
|
||||
/**
|
||||
* Overall wrapper for the picker.
|
||||
*/
|
||||
streamKeyPickerWrapper: {
|
||||
borderColor: BaseTheme.palette.ui07,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
/**
|
||||
* Terms and Conditions texts.
|
||||
*/
|
||||
tcText: {
|
||||
textAlign: 'right'
|
||||
},
|
||||
|
||||
text: {
|
||||
color: BaseTheme.palette.text01,
|
||||
fontSize: 14,
|
||||
textAlign: 'left'
|
||||
},
|
||||
|
||||
/**
|
||||
* A different colored text to indicate information needing attention.
|
||||
*/
|
||||
warningText: {
|
||||
color: BaseTheme.palette.warning02
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import AbstractLiveStreamButton, {
|
||||
IProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractLiveStreamButton';
|
||||
|
||||
import StartLiveStreamDialog from './StartLiveStreamDialog';
|
||||
import StopLiveStreamDialog from './StopLiveStreamDialog';
|
||||
|
||||
|
||||
/**
|
||||
* Button for opening the live stream settings dialog.
|
||||
*/
|
||||
class LiveStreamButton extends AbstractLiveStreamButton<IProps> {
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _onHandleClick() {
|
||||
const { _isLiveStreamRunning, dispatch } = this.props;
|
||||
const dialogComponent = _isLiveStreamRunning ? StopLiveStreamDialog : StartLiveStreamDialog;
|
||||
const dialogName = _isLiveStreamRunning ? 'StopLiveStreamDialog' : 'StartLiveStreamDialog';
|
||||
|
||||
dispatch(openDialog(dialogName, dialogComponent));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code LiveStreamButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {IProps} ownProps - The own props of the Component.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _conference: Object,
|
||||
* _isLiveStreamRunning: boolean,
|
||||
* _disabled: boolean,
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
const abstractProps = _abstractMapStateToProps(state, ownProps);
|
||||
const { toolbarButtons } = state['features/toolbox'];
|
||||
let { visible } = ownProps;
|
||||
|
||||
if (typeof visible === 'undefined') {
|
||||
visible = Boolean(toolbarButtons?.includes('livestreaming') && abstractProps.visible);
|
||||
}
|
||||
|
||||
return {
|
||||
...abstractProps,
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(LiveStreamButton));
|
||||
@@ -0,0 +1,360 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { translate } from '../../../../base/i18n/functions.web';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import Spinner from '../../../../base/ui/components/web/Spinner';
|
||||
import {
|
||||
loadGoogleAPI,
|
||||
requestAvailableYouTubeBroadcasts,
|
||||
requestLiveStreamsForYouTubeBroadcast,
|
||||
showAccountSelection,
|
||||
signIn,
|
||||
updateProfile
|
||||
} from '../../../../google-api/actions';
|
||||
import GoogleSignInButton from '../../../../google-api/components/GoogleSignInButton.web';
|
||||
import { GOOGLE_API_STATES } from '../../../../google-api/constants';
|
||||
import AbstractStartLiveStreamDialog, {
|
||||
IProps as AbstractProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractStartLiveStreamDialog';
|
||||
|
||||
import StreamKeyForm from './StreamKeyForm';
|
||||
import StreamKeyPicker from './StreamKeyPicker';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* The ID for the Google client application used for making stream key
|
||||
* related requests.
|
||||
*/
|
||||
_googleApiApplicationClientID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React Component for requesting a YouTube stream key to use for live
|
||||
* streaming of the current conference.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StartLiveStreamDialog
|
||||
extends AbstractStartLiveStreamDialog<IProps> {
|
||||
|
||||
/**
|
||||
* Initializes a new {@code StartLiveStreamDialog} instance.
|
||||
*
|
||||
* @param {IProps} props - The React {@code Component} props to initialize
|
||||
* the new {@code StartLiveStreamDialog} instance with.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this);
|
||||
this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this);
|
||||
this._onGoogleSignIn = this._onGoogleSignIn.bind(this);
|
||||
this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this);
|
||||
this._onYouTubeBroadcastIDSelected
|
||||
= this._onYouTubeBroadcastIDSelected.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link Component#componentDidMount()}. Invoked immediately
|
||||
* after this component is mounted.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
super.componentDidMount();
|
||||
|
||||
if (this.props._googleApiApplicationClientID) {
|
||||
this._onInitializeGoogleApi();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component}'s render.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { _googleApiApplicationClientID, t } = this.props;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ok = {{ translationKey: 'dialog.startLiveStreaming' }}
|
||||
onCancel = { this._onCancel }
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = 'liveStreaming.start'>
|
||||
<div className = 'live-stream-dialog'>
|
||||
{ _googleApiApplicationClientID
|
||||
? this._renderYouTubePanel() : null }
|
||||
<div className = 'youtube-go-live-warning'>
|
||||
{ t('liveStreaming.youTubeGoLiveWarning') }
|
||||
</div>
|
||||
<StreamKeyForm
|
||||
onChange = { this._onStreamKeyChange }
|
||||
value = {
|
||||
this.state.streamKey || this.props._streamKey || ''
|
||||
} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the Google web client application used for fetching stream keys.
|
||||
* If the user is already logged in, then a request for available YouTube
|
||||
* broadcasts is also made.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onInitializeGoogleApi() {
|
||||
this.props.dispatch(loadGoogleAPI())
|
||||
.catch((response: any) => this._parseErrorFromResponse(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically selects the input field's value after starting to edit the
|
||||
* display name.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidUpdate(previousProps: IProps) {
|
||||
if (previousProps._googleAPIState === GOOGLE_API_STATES.LOADED
|
||||
&& this.props._googleAPIState === GOOGLE_API_STATES.SIGNED_IN) {
|
||||
this._onGetYouTubeBroadcasts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user to sign in, if not already signed in, and then requests a
|
||||
* list of the user's YouTube broadcasts.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
override _onGetYouTubeBroadcasts() {
|
||||
this.props.dispatch(updateProfile())
|
||||
.catch((response: any) => this._parseErrorFromResponse(response));
|
||||
|
||||
this.props.dispatch(requestAvailableYouTubeBroadcasts())
|
||||
.then((broadcasts: { boundStreamID: string; }[]) => {
|
||||
this._setStateIfMounted({
|
||||
broadcasts
|
||||
});
|
||||
|
||||
if (broadcasts.length === 1) {
|
||||
const broadcast = broadcasts[0];
|
||||
|
||||
this._onYouTubeBroadcastIDSelected(broadcast.boundStreamID);
|
||||
}
|
||||
})
|
||||
.catch((response: any) => this._parseErrorFromResponse(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the Google web client application to prompt for a sign in, such as
|
||||
* when changing account, and will then fetch available YouTube broadcasts.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_onGoogleSignIn() {
|
||||
this.props.dispatch(signIn())
|
||||
.catch((response: any) => this._parseErrorFromResponse(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the Google web client application to prompt for a sign in, such as
|
||||
* when changing account, and will then fetch available YouTube broadcasts.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_onRequestGoogleSignIn() {
|
||||
// when there is an error we show the google sign-in button.
|
||||
// once we click it we want to clear the error from the state
|
||||
this.props.dispatch(showAccountSelection())
|
||||
.then(() =>
|
||||
this._setStateIfMounted({
|
||||
broadcasts: undefined,
|
||||
errorType: undefined
|
||||
}))
|
||||
.then(() => this._onGetYouTubeBroadcasts());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the stream key for a YouTube broadcast and updates the internal
|
||||
* state to display the associated stream key as being entered.
|
||||
*
|
||||
* @param {string} boundStreamID - The bound stream ID associated with the
|
||||
* broadcast from which to get the stream key.
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_onYouTubeBroadcastIDSelected(boundStreamID: string) {
|
||||
this.props.dispatch(
|
||||
requestLiveStreamsForYouTubeBroadcast(boundStreamID))
|
||||
.then(({ streamKey, selectedBoundStreamID }: { selectedBoundStreamID: string; streamKey: string; }) =>
|
||||
this._setStateIfMounted({
|
||||
streamKey,
|
||||
selectedBoundStreamID
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Only show an error if an external request was made with the Google api.
|
||||
* Do not error if the login in canceled.
|
||||
* And searches in a Google API error response for the error type.
|
||||
*
|
||||
* @param {Object} response - The Google API response that may contain an
|
||||
* error.
|
||||
* @private
|
||||
* @returns {string|null}
|
||||
*/
|
||||
_parseErrorFromResponse(response: any) {
|
||||
|
||||
if (!response?.result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = response.result;
|
||||
const error = result.error;
|
||||
const errors = error?.errors;
|
||||
const firstError = errors?.[0];
|
||||
|
||||
this._setStateIfMounted({
|
||||
errorType: firstError?.reason || null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a React Element for authenticating with the Google web client.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderYouTubePanel() {
|
||||
const {
|
||||
t,
|
||||
_googleProfileEmail
|
||||
} = this.props;
|
||||
const {
|
||||
broadcasts,
|
||||
selectedBoundStreamID
|
||||
} = this.state;
|
||||
|
||||
let googleContent, helpText;
|
||||
|
||||
switch (this.props._googleAPIState) {
|
||||
case GOOGLE_API_STATES.LOADED:
|
||||
googleContent
|
||||
= <GoogleSignInButton onClick = { this._onGoogleSignIn } />;
|
||||
helpText = t('liveStreaming.signInCTA');
|
||||
|
||||
break;
|
||||
|
||||
case GOOGLE_API_STATES.SIGNED_IN:
|
||||
if (broadcasts) {
|
||||
googleContent = (
|
||||
<StreamKeyPicker
|
||||
broadcasts = { broadcasts }
|
||||
onBroadcastSelected
|
||||
= { this._onYouTubeBroadcastIDSelected }
|
||||
selectedBoundStreamID = { selectedBoundStreamID } />
|
||||
);
|
||||
} else {
|
||||
googleContent
|
||||
= <Spinner />
|
||||
;
|
||||
}
|
||||
|
||||
helpText = (
|
||||
<Trans
|
||||
components = { [ <a
|
||||
key = 'change-sign-in'
|
||||
onClick = { this._onRequestGoogleSignIn } /> ] }
|
||||
i18nKey = 'liveStreaming.chooseCTAWithChangeSignIn'
|
||||
t = { t }
|
||||
values = {{ email: _googleProfileEmail }} />
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case GOOGLE_API_STATES.NEEDS_LOADING:
|
||||
default:
|
||||
googleContent
|
||||
= <Spinner />
|
||||
;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.state.errorType !== undefined) {
|
||||
googleContent = (
|
||||
<GoogleSignInButton
|
||||
onClick = { this._onRequestGoogleSignIn } />
|
||||
);
|
||||
helpText = this._getGoogleErrorMessageToDisplay();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = 'google-panel'>
|
||||
<div className = 'live-stream-cta'>
|
||||
{ helpText }
|
||||
</div>
|
||||
<div className = 'google-api'>
|
||||
{ googleContent }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error message to display for the current error state.
|
||||
*
|
||||
* @private
|
||||
* @returns {string} The error message to display.
|
||||
*/
|
||||
_getGoogleErrorMessageToDisplay() {
|
||||
let text;
|
||||
|
||||
switch (this.state.errorType) {
|
||||
case 'liveStreamingNotEnabled':
|
||||
text = this.props.t(
|
||||
'liveStreaming.errorLiveStreamNotEnabled',
|
||||
{ email: this.props._googleProfileEmail });
|
||||
break;
|
||||
default:
|
||||
text = this.props.t('liveStreaming.errorAPI');
|
||||
break;
|
||||
}
|
||||
|
||||
return <div className = 'google-error'>{ text }</div>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the component's props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {{
|
||||
* _googleApiApplicationClientID: string
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
_googleApiApplicationClientID:
|
||||
state['features/base/config'].googleApiApplicationClientID
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import AbstractStopLiveStreamDialog, {
|
||||
_mapStateToProps
|
||||
} from '../AbstractStopLiveStreamDialog';
|
||||
|
||||
/**
|
||||
* A React Component for confirming the participant wishes to stop the currently
|
||||
* active live stream of the conference.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StopLiveStreamDialog extends AbstractStopLiveStreamDialog {
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<Dialog
|
||||
ok = {{ translationKey: 'dialog.stopLiveStreaming' }}
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = 'dialog.liveStreaming'>
|
||||
{ this.props.t('dialog.stopStreamingWarning') }
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Input from '../../../../base/ui/components/web/Input';
|
||||
import AbstractStreamKeyForm, {
|
||||
IProps as AbstractProps,
|
||||
_mapStateToProps
|
||||
} from '../AbstractStreamKeyForm';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* An object containing the CSS classes.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
helperLink: {
|
||||
cursor: 'pointer',
|
||||
color: theme.palette.link01,
|
||||
transition: 'color .2s ease',
|
||||
...theme.typography.labelBold,
|
||||
marginLeft: 'auto',
|
||||
marginTop: theme.spacing(1),
|
||||
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
color: theme.palette.link01Hover
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
color: theme.palette.link01Active
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A React Component for entering a key for starting a YouTube live stream.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { t, value } = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<div className = 'stream-key-form'>
|
||||
<Input
|
||||
autoFocus = { true }
|
||||
id = 'streamkey-input'
|
||||
label = { t('dialog.streamKey') }
|
||||
name = 'streamId'
|
||||
onChange = { this._onInputChange }
|
||||
placeholder = { t('liveStreaming.enterStreamKey') }
|
||||
type = 'text'
|
||||
value = { value } />
|
||||
<div className = 'form-footer'>
|
||||
<div className = 'help-container'>
|
||||
{
|
||||
this.state.showValidationError
|
||||
? <span className = 'warning-text'>
|
||||
{ t('liveStreaming.invalidStreamKey') }
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
{ this.props._liveStreaming.helpURL
|
||||
? <a
|
||||
className = { classes.helperLink }
|
||||
href = { this.props._liveStreaming.helpURL }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
{ t('liveStreaming.streamIdHelp') }
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<a
|
||||
className = { classes.helperLink }
|
||||
href = { this.props._liveStreaming.termsURL }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
{ t('liveStreaming.youtubeTerms') }
|
||||
</a>
|
||||
<a
|
||||
className = { classes.helperLink }
|
||||
href = { this.props._liveStreaming.dataPrivacyURL }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
{ t('liveStreaming.googlePrivacyPolicy') }
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(withStyles(StreamKeyForm, styles)));
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Select from '../../../../base/ui/components/web/Select';
|
||||
import { YOUTUBE_LIVE_DASHBOARD_URL } from '../constants';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link StreamKeyPicker}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Broadcasts available for selection. Each broadcast item should be an
|
||||
* object with a title for display in the dropdown and a boundStreamID to
|
||||
* return in the {@link onBroadcastSelected} callback.
|
||||
*/
|
||||
broadcasts: Array<{
|
||||
boundStreamID: string;
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Callback invoked when an item in the dropdown is selected. The selected
|
||||
* broadcast's boundStreamID will be passed back.
|
||||
*/
|
||||
onBroadcastSelected: Function;
|
||||
|
||||
/**
|
||||
* The boundStreamID of the broadcast that should display as selected in the
|
||||
* dropdown.
|
||||
*/
|
||||
selectedBoundStreamID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dropdown to select a YouTube broadcast.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StreamKeyPicker extends PureComponent<IProps> {
|
||||
/**
|
||||
* Default values for {@code StreamKeyForm} component's properties.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static defaultProps = {
|
||||
broadcasts: []
|
||||
};
|
||||
|
||||
/**
|
||||
* The initial state of a {@code StreamKeyForm} instance.
|
||||
*/
|
||||
override state = {
|
||||
isDropdownOpen: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a new {@code StreamKeyPicker} instance.
|
||||
*
|
||||
* @param {IProps} props - The React {@code Component} props to initialize
|
||||
* the new {@code StreamKeyPicker} instance with.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onSelect = this._onSelect.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { broadcasts, selectedBoundStreamID, t } = this.props;
|
||||
|
||||
if (!broadcasts.length) {
|
||||
return (
|
||||
<a
|
||||
className = 'warning-text'
|
||||
href = { YOUTUBE_LIVE_DASHBOARD_URL }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
{ t('liveStreaming.getStreamKeyManually') }
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const dropdownItems
|
||||
= broadcasts.map(broadcast => {
|
||||
return {
|
||||
value: broadcast.boundStreamID,
|
||||
label: broadcast.title
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className = 'broadcast-dropdown dropdown-menu'>
|
||||
<Select
|
||||
id = 'streamkeypicker-select'
|
||||
label = { t('liveStreaming.choose') }
|
||||
onChange = { this._onSelect }
|
||||
options = { dropdownItems }
|
||||
value = { selectedBoundStreamID ?? '' } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when an item has been clicked in the dropdown menu.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const streamId = e.target.value;
|
||||
|
||||
this.props.onBroadcastSelected(streamId);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(StreamKeyPicker);
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { MEET_FEATURES } from '../../../base/jwt/constants';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
|
||||
import { hideNotification, showNotification } from '../../../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../../notifications/constants';
|
||||
import { iAmVisitor } from '../../../visitors/functions';
|
||||
import { highlightMeetingMoment } from '../../actions.any';
|
||||
import { PROMPT_RECORDING_NOTIFICATION_ID } from '../../constants';
|
||||
import {
|
||||
getRecordButtonProps,
|
||||
isCloudRecordingRunning,
|
||||
isHighlightMeetingMomentDisabled
|
||||
} from '../../functions';
|
||||
|
||||
import { RecordingTranscriptionDialog } from './index';
|
||||
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Indicates whether or not the button is disabled.
|
||||
*/
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether or not a highlight request is in progress.
|
||||
*/
|
||||
_isHighlightInProgress: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the button should be visible.
|
||||
*/
|
||||
_visible: boolean;
|
||||
|
||||
/**
|
||||
* Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class for the {@code AbstractHighlightButton} component.
|
||||
*/
|
||||
export default class AbstractHighlightButton<P extends IProps, S={}> extends Component<P, S> {
|
||||
/**
|
||||
* Initializes a new AbstractHighlightButton instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: P) {
|
||||
super(props);
|
||||
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
const { _disabled, _isHighlightInProgress, dispatch } = this.props;
|
||||
|
||||
if (_isHighlightInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_disabled) {
|
||||
dispatch(showNotification({
|
||||
descriptionKey: 'recording.highlightMomentDisabled',
|
||||
titleKey: 'recording.highlightMoment',
|
||||
uid: PROMPT_RECORDING_NOTIFICATION_ID,
|
||||
customActionNameKey: [ 'localRecording.start' ],
|
||||
customActionHandler: [ () => {
|
||||
dispatch(hideNotification(PROMPT_RECORDING_NOTIFICATION_ID));
|
||||
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
|
||||
|
||||
if (!dialogShown) {
|
||||
dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
|
||||
}
|
||||
} ],
|
||||
appearance: NOTIFICATION_TYPE.NORMAL
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
} else {
|
||||
dispatch(highlightMeetingMoment());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated
|
||||
* {@code AbstractHighlightButton}'s props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _disabled: boolean,
|
||||
* _isHighlightInProgress: boolean,
|
||||
* _visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function _abstractMapStateToProps(state: IReduxState) {
|
||||
const isRecordingRunning = isCloudRecordingRunning(state);
|
||||
const isButtonDisabled = isHighlightMeetingMomentDisabled(state);
|
||||
const { webhookProxyUrl } = state['features/base/config'];
|
||||
const _iAmVisitor = iAmVisitor(state);
|
||||
const {
|
||||
disabled: isRecordButtonDisabled,
|
||||
visible: isRecordButtonVisible
|
||||
} = getRecordButtonProps(state);
|
||||
|
||||
const canStartRecording = isRecordButtonVisible && !isRecordButtonDisabled;
|
||||
const _visible = Boolean((canStartRecording || isRecordingRunning) && Boolean(webhookProxyUrl) && !_iAmVisitor);
|
||||
|
||||
return {
|
||||
_disabled: !isRecordingRunning,
|
||||
_isHighlightInProgress: isButtonDisabled,
|
||||
_visible
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { IconRecord, IconStop } from '../../../base/icons/svg';
|
||||
import { MEET_FEATURES } from '../../../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../../../base/jwt/functions';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants/functions';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
|
||||
import { canAddTranscriber } from '../../../transcribing/functions';
|
||||
import { canStopRecording, getRecordButtonProps, supportsLocalRecording } from '../../functions';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link AbstractRecordButton}.
|
||||
*/
|
||||
export interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* True if the local participant can start/stop transcription.
|
||||
*/
|
||||
_canTranscribe: boolean;
|
||||
|
||||
/**
|
||||
* True if the button needs to be disabled.
|
||||
*/
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* True if there is a running active recording, false otherwise.
|
||||
*/
|
||||
_isRecordingRunning: boolean;
|
||||
|
||||
/**
|
||||
* The tooltip to display when hovering over the button.
|
||||
*/
|
||||
_tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An abstract implementation of a button for starting and stopping recording.
|
||||
*/
|
||||
export default class AbstractRecordButton<P extends IProps> extends AbstractButton<P> {
|
||||
override accessibilityLabel = 'toolbar.recordAndTranscribe';
|
||||
override toggledAccessibilityLabel = 'toolbar.recordAndTranscribe';
|
||||
override icon = IconRecord;
|
||||
override label = 'toolbar.recordAndTranscribe';
|
||||
override toggledLabel = 'toolbar.recordAndTranscribe';
|
||||
override toggledIcon = IconStop;
|
||||
|
||||
override _getLabel() {
|
||||
return this.props._canTranscribe ? super._getLabel() : 'toolbar.record';
|
||||
}
|
||||
|
||||
override _getAccessibilityLabel() {
|
||||
return this.props._canTranscribe ? super._getAccessibilityLabel() : 'toolbar.record';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tooltip that should be displayed when the button is disabled.
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
override _getTooltip() {
|
||||
return this.props._tooltip ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to be implemented by subclasses, which should be used
|
||||
* to handle the start recoding button being clicked / pressed.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onHandleClick() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { _isRecordingRunning, dispatch } = this.props;
|
||||
|
||||
sendAnalytics(createToolbarEvent(
|
||||
'recording.button',
|
||||
{
|
||||
'is_recording': _isRecordingRunning,
|
||||
type: JitsiRecordingConstants.mode.FILE
|
||||
}));
|
||||
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
|
||||
|
||||
if (!dialogShown) {
|
||||
this._onHandleClick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to be implemented by subclasses, which must return a
|
||||
* boolean value indicating if this button is disabled or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isDisabled() {
|
||||
return this.props._disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in toggled state or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return this.props._isRecordingRunning;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code RecordButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _disabled: boolean,
|
||||
* _isRecordingRunning: boolean,
|
||||
* _tooltip: string,
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
disabled: _disabled,
|
||||
tooltip: _tooltip,
|
||||
visible
|
||||
} = getRecordButtonProps(state);
|
||||
const { localRecording } = state['features/base/config'];
|
||||
const localRecordingEnabled = !localRecording?.disable && supportsLocalRecording();
|
||||
const isModerator = isLocalParticipantModerator(state);
|
||||
const hasRecordingJwt = isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false);
|
||||
|
||||
return {
|
||||
_canTranscribe: canAddTranscriber(state),
|
||||
_disabled,
|
||||
_isRecordingRunning: canStopRecording(state),
|
||||
_tooltip,
|
||||
visible: visible && (isModerator || localRecordingEnabled || hasRecordingJwt)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { createRecordingDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { DEFAULT_LANGUAGE } from '../../../base/i18n/i18next';
|
||||
import { MEET_FEATURES } from '../../../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../../../base/jwt/functions';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants/functions';
|
||||
import { updateDropboxToken } from '../../../dropbox/actions';
|
||||
import { getNewAccessToken, isEnabled as isDropboxEnabled } from '../../../dropbox/functions';
|
||||
import { getDropboxData } from '../../../dropbox/functions.any';
|
||||
import { showErrorNotification } from '../../../notifications/actions';
|
||||
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
|
||||
import { canAddTranscriber, isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
|
||||
import {
|
||||
setSelectedRecordingService,
|
||||
setStartRecordingIntent,
|
||||
setStopRecordingIntent,
|
||||
startLocalVideoRecording,
|
||||
stopLocalVideoRecording
|
||||
} from '../../actions.any';
|
||||
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../constants';
|
||||
import {
|
||||
getActiveSession,
|
||||
hasRecordingOrTranscriptionFeature,
|
||||
isRecordingRunning,
|
||||
isRecordingSharingEnabled,
|
||||
shouldAutoTranscribeOnRecord,
|
||||
supportsLocalRecording
|
||||
} from '../../functions';
|
||||
import { ISessionData } from '../../reducer';
|
||||
|
||||
/**
|
||||
* The set of start/stop operations to apply on the running services. Each
|
||||
* operation is applied only when its flag is true.
|
||||
*/
|
||||
interface IRecordingChanges {
|
||||
startRecording?: boolean;
|
||||
startTranscription?: boolean;
|
||||
stopRecording?: boolean;
|
||||
stopTranscription?: boolean;
|
||||
}
|
||||
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The app key for the dropbox authentication.
|
||||
*/
|
||||
_appKey: string;
|
||||
|
||||
/**
|
||||
* Requests transcribing when recording is turned on.
|
||||
*/
|
||||
_autoTranscribeOnRecord: boolean;
|
||||
|
||||
/**
|
||||
* Whether the local participant can start/stop transcription.
|
||||
*/
|
||||
_canTranscribe: boolean;
|
||||
|
||||
/**
|
||||
* The {@code JitsiConference} for the current conference.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* Whether subtitles should be displayed or not.
|
||||
*/
|
||||
_displaySubtitles?: boolean;
|
||||
|
||||
/**
|
||||
* Active FILE recording session, needed to stop recording.
|
||||
*/
|
||||
_fileRecordingSession?: ISessionData;
|
||||
|
||||
/**
|
||||
* Whether to show file recordings service, even if integrations
|
||||
* are enabled.
|
||||
*/
|
||||
_fileRecordingsServiceEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the possibility to share file recording with other people (e.g. Meeting participants), based on
|
||||
* the actual implementation on the backend.
|
||||
*/
|
||||
_fileRecordingsServiceSharingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* If true the dropbox integration is enabled, otherwise - disabled.
|
||||
*/
|
||||
_isDropboxEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether the local participant is a moderator.
|
||||
*/
|
||||
_isModerator: boolean;
|
||||
|
||||
/**
|
||||
* Whether a local recording is currently active.
|
||||
*/
|
||||
_localRecording?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not local recording is enabled.
|
||||
*/
|
||||
_localRecordingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The dropbox refresh token.
|
||||
*/
|
||||
_rToken: string;
|
||||
|
||||
/**
|
||||
* Whether file recording is currently running.
|
||||
*/
|
||||
_recordingRunning?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the local participant is screensharing.
|
||||
*/
|
||||
_screensharing: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the screenshot capture feature is enabled.
|
||||
*/
|
||||
_screenshotCaptureEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The selected language for subtitles.
|
||||
*/
|
||||
_subtitlesLanguage: string | null;
|
||||
|
||||
/**
|
||||
* The dropbox access token.
|
||||
*/
|
||||
_token: string;
|
||||
|
||||
/**
|
||||
* Access token's expiration date as UNIX timestamp.
|
||||
*/
|
||||
_tokenExpireDate?: number;
|
||||
|
||||
/**
|
||||
* Whether transcription is currently running.
|
||||
*/
|
||||
_transcriptionRunning?: boolean;
|
||||
|
||||
/**
|
||||
* The redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
navigation: any;
|
||||
|
||||
/**
|
||||
* When false, the recording section is collapsed on open, putting the
|
||||
* focus on the transcription section (e.g. subtitles/nudge flows).
|
||||
*/
|
||||
recordAudioAndVideo?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* <tt>true</tt> if we have valid oauth token.
|
||||
*/
|
||||
isTokenValid: boolean;
|
||||
|
||||
/**
|
||||
* <tt>true</tt> if we are in process of validating the oauth token.
|
||||
*/
|
||||
isValidating: boolean;
|
||||
|
||||
/**
|
||||
* Whether the local recording should record just the local user streams.
|
||||
*/
|
||||
localRecordingOnlySelf: boolean;
|
||||
|
||||
/**
|
||||
* The language ("translation-languages:" prefixed) the transcription will
|
||||
* be saved in.
|
||||
*/
|
||||
selectedLanguage: string | null;
|
||||
|
||||
/**
|
||||
* The currently selected recording service of type: RECORDING_TYPES.
|
||||
*/
|
||||
selectedRecordingService: string;
|
||||
|
||||
/**
|
||||
* True if the user requested the service to share the recording with others.
|
||||
*/
|
||||
sharingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Number of MiB of available space in user's Dropbox account.
|
||||
*/
|
||||
spaceLeft?: number;
|
||||
|
||||
/**
|
||||
* The display name of the user's Dropbox account.
|
||||
*/
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the recording & transcription dialog. Exposes independent
|
||||
* start/stop handlers for each service plus combined "both" handlers; each
|
||||
* handler applies its changes immediately.
|
||||
*/
|
||||
class AbstractStartRecordingDialog extends Component<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new {@code StartRecordingDialog} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onSelectedRecordingServiceChanged
|
||||
= this._onSelectedRecordingServiceChanged.bind(this);
|
||||
this._onSharingSettingChanged = this._onSharingSettingChanged.bind(this);
|
||||
this._toggleScreenshotCapture = this._toggleScreenshotCapture.bind(this);
|
||||
this._onLocalRecordingSelfChange = this._onLocalRecordingSelfChange.bind(this);
|
||||
this._onSubtitlesLanguageChanged = this._onSubtitlesLanguageChanged.bind(this);
|
||||
this._onStartRecording = this._onStartRecording.bind(this);
|
||||
this._onStopRecording = this._onStopRecording.bind(this);
|
||||
this._onStartTranscription = this._onStartTranscription.bind(this);
|
||||
this._onStopTranscription = this._onStopTranscription.bind(this);
|
||||
this._onStartBoth = this._onStartBoth.bind(this);
|
||||
this._onStopBoth = this._onStopBoth.bind(this);
|
||||
this._onStartRecordingPress = this._onStartRecordingPress.bind(this);
|
||||
this._onStopRecordingPress = this._onStopRecordingPress.bind(this);
|
||||
this._onStartTranscriptionPress = this._onStartTranscriptionPress.bind(this);
|
||||
this._onStopTranscriptionPress = this._onStopTranscriptionPress.bind(this);
|
||||
this._onStartBothPress = this._onStartBothPress.bind(this);
|
||||
this._onStopBothPress = this._onStopBothPress.bind(this);
|
||||
|
||||
this.state = {
|
||||
isTokenValid: false,
|
||||
isValidating: false,
|
||||
userName: undefined,
|
||||
sharingEnabled: true,
|
||||
selectedLanguage: props._subtitlesLanguage
|
||||
?? `translation-languages:${DEFAULT_LANGUAGE}`,
|
||||
spaceLeft: undefined,
|
||||
selectedRecordingService: this._getDefaultRecordingService(props),
|
||||
localRecordingOnlySelf: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the default recording service to preselect from the given props: the file recordings
|
||||
* service takes priority, then local recording (unless an integration is enabled and preferred
|
||||
* instead), then Dropbox, in that order. Returns '' if none is available, in which case the
|
||||
* start recording button is disabled.
|
||||
*
|
||||
* @param {IProps} props - The props to compute the default from.
|
||||
* @returns {string} - One of RECORDING_TYPES, or ''.
|
||||
*/
|
||||
_getDefaultRecordingService(props: IProps): string {
|
||||
if (props._fileRecordingsServiceEnabled) {
|
||||
return RECORDING_TYPES.JITSI_REC_SERVICE;
|
||||
}
|
||||
|
||||
if (this._areIntegrationsEnabled(props)) {
|
||||
if (props._localRecordingEnabled && supportsLocalRecording()) {
|
||||
return RECORDING_TYPES.LOCAL;
|
||||
}
|
||||
|
||||
return RECORDING_TYPES.DROPBOX;
|
||||
}
|
||||
|
||||
if (props._localRecordingEnabled && supportsLocalRecording()) {
|
||||
return RECORDING_TYPES.LOCAL;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the oauth access token.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
if (typeof this.props._token !== 'undefined') {
|
||||
this._onTokenUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the oauth access token.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidUpdate(prevProps: IProps) {
|
||||
if (this.props._token !== prevProps._token) {
|
||||
this._onTokenUpdated();
|
||||
}
|
||||
|
||||
// selectedRecordingService is otherwise only ever set once, from props, in the
|
||||
// constructor. If none of the services this dialog cares about were available/known yet
|
||||
// at construction time (e.g. base/config hadn't finished loading when this instance
|
||||
// mounted), it gets stuck at '' — permanently disabling the start button — even after the
|
||||
// props that actually determine availability catch up moments later. Recompute it
|
||||
// whenever those specific props change, but only while nothing has been selected yet, so
|
||||
// this never overrides an explicit choice made via _onSelectedRecordingServiceChanged.
|
||||
if (this.state.selectedRecordingService === ''
|
||||
&& (prevProps._fileRecordingsServiceEnabled !== this.props._fileRecordingsServiceEnabled
|
||||
|| prevProps._isDropboxEnabled !== this.props._isDropboxEnabled
|
||||
|| prevProps._localRecordingEnabled !== this.props._localRecordingEnabled)) {
|
||||
const selectedRecordingService = this._getDefaultRecordingService(this.props);
|
||||
|
||||
if (selectedRecordingService) {
|
||||
this.setState({ selectedRecordingService });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the integrations with third party services are enabled
|
||||
* and false otherwise.
|
||||
*
|
||||
* @param {IProps} props - The props to check against; defaults to this.props.
|
||||
* @returns {boolean} - True if the integrations with third party services
|
||||
* are enabled and false otherwise.
|
||||
*/
|
||||
_areIntegrationsEnabled(props: IProps = this.props) {
|
||||
return props._isDropboxEnabled
|
||||
&& !props._recordingRunning
|
||||
&& !props._transcriptionRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle sharing setting change from the dialog.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSharingSettingChanged() {
|
||||
this.setState({
|
||||
sharingEnabled: !this.state.sharingEnabled
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle local recording only self setting change.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLocalRecordingSelfChange() {
|
||||
this.setState({
|
||||
localRecordingOnlySelf: !this.state.localRecordingOnlySelf
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles selected recording service changes.
|
||||
*
|
||||
* @param {string} selectedRecordingService - The new selected recording
|
||||
* service.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSelectedRecordingServiceChanged(selectedRecordingService: string) {
|
||||
this.setState({ selectedRecordingService }, () => {
|
||||
this.props.dispatch(setSelectedRecordingService(selectedRecordingService));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles transcription language changes.
|
||||
*
|
||||
* @param {string} selectedLanguage - The new ("translation-languages:"
|
||||
* prefixed) language.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSubtitlesLanguageChanged(selectedLanguage: string) {
|
||||
this.setState({ selectedLanguage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the dropbox access token and fetches account information.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onTokenUpdated() {
|
||||
const { _appKey, _isDropboxEnabled, _token, _rToken, _tokenExpireDate, dispatch } = this.props;
|
||||
|
||||
if (!_isDropboxEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof _token === 'undefined') {
|
||||
this.setState({
|
||||
isTokenValid: false,
|
||||
isValidating: false
|
||||
});
|
||||
} else { // @ts-ignore
|
||||
if (_tokenExpireDate && Date.now() > new Date(_tokenExpireDate)) {
|
||||
getNewAccessToken(_appKey, _rToken)
|
||||
.then((resp: { expireDate: number; rToken: string; token: string; }) =>
|
||||
dispatch(updateDropboxToken(resp.token, resp.rToken, resp.expireDate)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isTokenValid: false,
|
||||
isValidating: true
|
||||
});
|
||||
getDropboxData(_token, _appKey).then(data => {
|
||||
if (typeof data === 'undefined') {
|
||||
this.setState({
|
||||
isTokenValid: false,
|
||||
isValidating: false
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
isTokenValid: true,
|
||||
isValidating: false,
|
||||
...data
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the start recording button should be disabled: either
|
||||
* no recording service is available or the selected one is not ready to
|
||||
* be used (e.g. Dropbox without a valid sign-in).
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isStartRecordingDisabled() {
|
||||
const { isTokenValid, selectedRecordingService } = this.state;
|
||||
|
||||
if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
|
||||
|| selectedRecordingService === RECORDING_TYPES.LOCAL) {
|
||||
return false;
|
||||
}
|
||||
if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
|
||||
return !isTokenValid;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the audio & video recording. When the deployment is configured
|
||||
* with {@code transcription.autoTranscribeOnRecord} the transcription is
|
||||
* started along with it.
|
||||
*
|
||||
* @returns {boolean} - True when the action was applied.
|
||||
*/
|
||||
_onStartRecording() {
|
||||
const { _autoTranscribeOnRecord, _canTranscribe, _transcriptionRunning } = this.props;
|
||||
|
||||
return this._applyChanges({
|
||||
startRecording: true,
|
||||
startTranscription: _autoTranscribeOnRecord && _canTranscribe && !_transcriptionRunning
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the audio & video recording, leaving a running transcription
|
||||
* untouched.
|
||||
*
|
||||
* @returns {boolean} - True when the action was applied.
|
||||
*/
|
||||
_onStopRecording() {
|
||||
return this._applyChanges({ stopRecording: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the transcription, leaving the recording state untouched.
|
||||
*
|
||||
* @returns {boolean} - True when the action was applied.
|
||||
*/
|
||||
_onStartTranscription() {
|
||||
return this._applyChanges({ startTranscription: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the transcription, leaving a running recording untouched.
|
||||
*
|
||||
* @returns {boolean} - True when the action was applied.
|
||||
*/
|
||||
_onStopTranscription() {
|
||||
return this._applyChanges({ stopTranscription: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts every service which is not running yet.
|
||||
*
|
||||
* @returns {boolean} - True when the action was applied.
|
||||
*/
|
||||
_onStartBoth() {
|
||||
const { _recordingRunning, _transcriptionRunning } = this.props;
|
||||
|
||||
return this._applyChanges({
|
||||
startRecording: !_recordingRunning,
|
||||
startTranscription: !_transcriptionRunning
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops every running service.
|
||||
*
|
||||
* @returns {boolean} - True when the action was applied.
|
||||
*/
|
||||
_onStopBoth() {
|
||||
const { _recordingRunning, _transcriptionRunning } = this.props;
|
||||
|
||||
return this._applyChanges({
|
||||
stopRecording: _recordingRunning,
|
||||
stopTranscription: _transcriptionRunning
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the requested recording/transcription changes by starting or
|
||||
* stopping each service accordingly.
|
||||
*
|
||||
* @param {IRecordingChanges} changes - Which services to start/stop.
|
||||
* @returns {boolean} - True when the changes were applied, false when the
|
||||
* action could not be performed (e.g. validation failure).
|
||||
*/
|
||||
_applyChanges({
|
||||
startRecording = false,
|
||||
startTranscription = false,
|
||||
stopRecording = false,
|
||||
stopTranscription = false
|
||||
}: IRecordingChanges) {
|
||||
const {
|
||||
_appKey,
|
||||
_conference,
|
||||
_displaySubtitles,
|
||||
_fileRecordingSession,
|
||||
_isDropboxEnabled,
|
||||
_localRecording,
|
||||
_recordingRunning = false,
|
||||
_rToken,
|
||||
_subtitlesLanguage,
|
||||
_token,
|
||||
_transcriptionRunning = false,
|
||||
dispatch
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
localRecordingOnlySelf,
|
||||
selectedLanguage,
|
||||
selectedRecordingService,
|
||||
sharingEnabled
|
||||
} = this.state;
|
||||
|
||||
// Pre-seed intents synchronously — must happen before any async operations
|
||||
// so the sound/notification coordinator knows what to wait for.
|
||||
if (startRecording || startTranscription) {
|
||||
dispatch(setStartRecordingIntent({
|
||||
recording: startRecording,
|
||||
transcription: startTranscription
|
||||
}));
|
||||
}
|
||||
if ((stopRecording || stopTranscription) && !_localRecording) {
|
||||
dispatch(setStopRecordingIntent({
|
||||
recording: stopRecording && Boolean(_fileRecordingSession),
|
||||
transcription: stopTranscription
|
||||
}));
|
||||
}
|
||||
|
||||
// === Stop recording ===
|
||||
if (stopRecording) {
|
||||
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
|
||||
if (_localRecording) {
|
||||
dispatch(stopLocalVideoRecording());
|
||||
} else if (_fileRecordingSession) {
|
||||
_conference?.stopRecording(_fileRecordingSession.id);
|
||||
this._toggleScreenshotCapture();
|
||||
// Keep isTranscribingEnabled in the metadata if transcription is still running,
|
||||
// so the metadata listener does not see a false transition on that field.
|
||||
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isRecordingRequested: false,
|
||||
...(_transcriptionRunning && !stopTranscription && { isTranscribingEnabled: true })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// === Stop transcription ===
|
||||
if (stopTranscription) {
|
||||
const recordingStillRunning = _recordingRunning && !stopRecording;
|
||||
|
||||
// When recording is still running, skip the subtitles-internal metadata write and do
|
||||
// a single write ourselves. This prevents two consecutive metadata transitions
|
||||
// (false→false on isRecordingRequested, then false→true) from firing spurious
|
||||
// "recording stopped" and "recording started" sounds and notifications.
|
||||
// Spread existing recording metadata so we only change isTranscribingEnabled —
|
||||
// hardcoding isRecordingRequested: true would cause a spurious recordingStarting
|
||||
// transition if that field was not previously set in the server metadata.
|
||||
dispatch(setRequestingSubtitles(false, _displaySubtitles, _subtitlesLanguage, true, false, recordingStillRunning));
|
||||
if (recordingStillRunning) {
|
||||
const existingRecMeta = _conference?.getMetadataHandler()?.getMetadata()[RECORDING_METADATA_ID] ?? {};
|
||||
|
||||
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
...existingRecMeta,
|
||||
isTranscribingEnabled: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// === Start recording ===
|
||||
if (startRecording) {
|
||||
let appData;
|
||||
const attributes: { type?: string; } = {};
|
||||
|
||||
switch (selectedRecordingService) {
|
||||
case RECORDING_TYPES.DROPBOX: {
|
||||
if (_isDropboxEnabled && _token) {
|
||||
appData = JSON.stringify({
|
||||
'file_recording_metadata': {
|
||||
'upload_credentials': {
|
||||
'service_name': RECORDING_TYPES.DROPBOX,
|
||||
'token': _token,
|
||||
'r_token': _rToken,
|
||||
'app_key': _appKey
|
||||
}
|
||||
}
|
||||
});
|
||||
attributes.type = RECORDING_TYPES.DROPBOX;
|
||||
} else {
|
||||
dispatch(showErrorNotification({ titleKey: 'dialog.noDropboxToken' }));
|
||||
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case RECORDING_TYPES.JITSI_REC_SERVICE: {
|
||||
appData = JSON.stringify({
|
||||
'file_recording_metadata': {
|
||||
'share': sharingEnabled
|
||||
}
|
||||
});
|
||||
attributes.type = RECORDING_TYPES.JITSI_REC_SERVICE;
|
||||
break;
|
||||
}
|
||||
case RECORDING_TYPES.LOCAL: {
|
||||
dispatch(startLocalVideoRecording(localRecordingOnlySelf));
|
||||
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isTranscribingEnabled:
|
||||
startTranscription || (_transcriptionRunning && !stopTranscription)
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
sendAnalytics(createRecordingDialogEvent('start', 'confirm.button', attributes));
|
||||
this._toggleScreenshotCapture();
|
||||
_conference?.startRecording({
|
||||
mode: JitsiRecordingConstants.mode.FILE,
|
||||
appData
|
||||
});
|
||||
}
|
||||
|
||||
// === Handle transcription start ===
|
||||
// JITSI_REC_SERVICE uses setRequestingSubtitles; other services update metadata directly.
|
||||
if (startTranscription) {
|
||||
if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) {
|
||||
dispatch(setRequestingSubtitles(
|
||||
true, _displaySubtitles, selectedLanguage, true, startRecording || _recordingRunning));
|
||||
} else {
|
||||
// Spread the existing metadata so that starting transcription while a non-Jitsi
|
||||
// recording is already running does not drop isRecordingRequested — that would
|
||||
// read as recording having stopped to the metadata listener (see the stopRecording
|
||||
// and stopTranscription branches above, which spread for the same reason).
|
||||
const existingRecMeta = _conference?.getMetadataHandler()?.getMetadata()[RECORDING_METADATA_ID] ?? {};
|
||||
|
||||
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
...existingRecMeta,
|
||||
...(startRecording && { isRecordingRequested: true }),
|
||||
isTranscribingEnabled: true
|
||||
});
|
||||
}
|
||||
} else if (startRecording) {
|
||||
// Recording started without a transcription change: announce it in room metadata so the
|
||||
// other participants get the recording start notification/sound (this is what the
|
||||
// metadata listener turns into the remote notification). Preserve any existing
|
||||
// isTranscribingEnabled so a running transcription is not signalled as stopped.
|
||||
const existingRecMeta = _conference?.getMetadataHandler()?.getMetadata()[RECORDING_METADATA_ID] ?? {};
|
||||
|
||||
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
...existingRecMeta,
|
||||
isRecordingRequested: true
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screenshot capture feature.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_toggleScreenshotCapture() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the dialog/screen once an action from the list below has been applied.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_dismiss() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the recording and dismisses the dialog/screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartRecordingPress() {
|
||||
this._onStartRecording() && this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the recording and dismisses the dialog/screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStopRecordingPress() {
|
||||
this._onStopRecording() && this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the transcription and dismisses the dialog/screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartTranscriptionPress() {
|
||||
this._onStartTranscription() && this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the transcription and dismisses the dialog/screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStopTranscriptionPress() {
|
||||
this._onStopTranscription() && this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts every service which is not running yet and dismisses the dialog/screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartBothPress() {
|
||||
this._onStartBoth() && this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops every running service and dismisses the dialog/screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStopBothPress() {
|
||||
this._onStopBoth() && this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the platform specific dialog content.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderDialogContent: () => React.Component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code StartRecordingDialog} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {any} _ownProps - Component's own props.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
const {
|
||||
recordingService,
|
||||
dropbox = { appKey: undefined },
|
||||
localRecording,
|
||||
recordings = { recordAudioAndVideo: true }
|
||||
} = state['features/base/config'];
|
||||
const {
|
||||
_displaySubtitles,
|
||||
_language: _subtitlesLanguage
|
||||
} = state['features/subtitles'];
|
||||
|
||||
// Only treat cloud recordings as "running" for users who can actually control them.
|
||||
// Non-mods without the recording/transcription JWT feature cannot stop cloud sessions,
|
||||
// so from their perspective only their own local recording counts as "running".
|
||||
const canManageRecordingOrTranscription = isLocalParticipantModerator(state)
|
||||
|| hasRecordingOrTranscriptionFeature(state);
|
||||
|
||||
return {
|
||||
_appKey: dropbox.appKey ?? '',
|
||||
_autoTranscribeOnRecord: shouldAutoTranscribeOnRecord(state),
|
||||
_canTranscribe: canAddTranscriber(state),
|
||||
_conference: state['features/base/conference'].conference,
|
||||
_displaySubtitles,
|
||||
_fileRecordingSession: getActiveSession(state, JitsiRecordingConstants.mode.FILE),
|
||||
_fileRecordingsServiceEnabled: recordingService?.enabled ?? false,
|
||||
_fileRecordingsServiceSharingEnabled: isRecordingSharingEnabled(state),
|
||||
_isModerator: isLocalParticipantModerator(state),
|
||||
_isDropboxEnabled: isDropboxEnabled(state),
|
||||
_localRecording: Boolean(state['features/recording'].localRecordingRunning),
|
||||
_localRecordingEnabled: !localRecording?.disable,
|
||||
_recordingRunning: canManageRecordingOrTranscription
|
||||
? isRecordingRunning(state)
|
||||
: Boolean(state['features/recording'].localRecordingRunning),
|
||||
_rToken: state['features/dropbox'].rToken ?? '',
|
||||
_transcriptionRunning: canManageRecordingOrTranscription
|
||||
? isRecorderTranscriptionsRunning(state)
|
||||
: false,
|
||||
recordAudioAndVideo:
|
||||
isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false)
|
||||
? _ownProps.recordAudioAndVideo ?? recordings?.recordAudioAndVideo ?? true : false,
|
||||
_subtitlesLanguage,
|
||||
_tokenExpireDate: state['features/dropbox'].expireDate,
|
||||
_token: state['features/dropbox'].token ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export default AbstractStartRecordingDialog;
|
||||
@@ -0,0 +1,668 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { createRecordingDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import ColorSchemeRegistry from '../../../base/color-scheme/ColorSchemeRegistry';
|
||||
import { _abstractMapStateToProps } from '../../../base/dialog/functions';
|
||||
import { MEET_FEATURES } from '../../../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../../../base/jwt/functions';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants/functions';
|
||||
import { authorizeDropbox, updateDropboxToken } from '../../../dropbox/actions';
|
||||
import { setFollowMeRecorderExclusive } from '../../../follow-me/actions';
|
||||
import { isFollowMeActive, isFollowMeRecorderActive } from '../../../follow-me/functions';
|
||||
import { isVpaasMeeting } from '../../../jaas/functions';
|
||||
import { getAvailableSubtitlesLanguages } from '../../../subtitles/functions.any';
|
||||
import { canAddTranscriber, isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
|
||||
import { RECORDING_TYPES } from '../../constants';
|
||||
import { hasRecordingOrTranscriptionFeature, isLiveStreamingRunning, supportsLocalRecording } from '../../functions';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link AbstractStartRecordingDialogContent}.
|
||||
*/
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The available ("translation-languages:" prefixed) transcription
|
||||
* languages.
|
||||
*/
|
||||
_availableLanguages: Array<string>;
|
||||
|
||||
/**
|
||||
* Whether the local participant can manage recording/transcription (moderator or holds the
|
||||
* recording/transcription feature claim).
|
||||
*/
|
||||
_canManageRecordingOrTranscription: boolean;
|
||||
|
||||
/**
|
||||
* Whether the local participant can start transcribing.
|
||||
*/
|
||||
_canStartTranscribing: boolean;
|
||||
|
||||
/**
|
||||
* Style of the dialogs feature.
|
||||
*/
|
||||
_dialogStyles: any;
|
||||
|
||||
/**
|
||||
* Whether any moderator has the regular Follow Me feature active.
|
||||
*/
|
||||
_followMeActive: boolean;
|
||||
|
||||
/**
|
||||
* Whether any moderator has the recorder Follow Me feature active.
|
||||
*/
|
||||
_followMeRecorderActive: boolean;
|
||||
|
||||
/**
|
||||
* Whether the local participant has the recorder Follow Me setting enabled.
|
||||
*/
|
||||
_followMeRecorderEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to hide the storage warning or not.
|
||||
*/
|
||||
_hideStorageWarning: boolean;
|
||||
|
||||
/**
|
||||
* Whether a live stream session is currently active.
|
||||
*/
|
||||
_isLiveStreamRunning: boolean;
|
||||
|
||||
/**
|
||||
* Whether the local participant is a moderator.
|
||||
*/
|
||||
_isModerator: boolean;
|
||||
|
||||
/**
|
||||
* Whether local recording is available or not.
|
||||
*/
|
||||
_localRecordingAvailable: boolean;
|
||||
|
||||
/**
|
||||
* Whether local recording is enabled or not.
|
||||
*/
|
||||
_localRecordingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether we won't notify the other participants about the recording.
|
||||
*/
|
||||
_localRecordingNoNotification: boolean;
|
||||
|
||||
/**
|
||||
* Whether a local recording is currently in progress.
|
||||
*/
|
||||
_localRecordingRunning: boolean;
|
||||
|
||||
/**
|
||||
* Whether self local recording is enabled or not.
|
||||
*/
|
||||
_localRecordingSelfEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to render recording.
|
||||
*/
|
||||
_renderRecording: boolean;
|
||||
|
||||
/**
|
||||
* The color-schemed stylesheet of this component.
|
||||
*/
|
||||
_styles: any;
|
||||
|
||||
/**
|
||||
* Whether transcription is currently running.
|
||||
*/
|
||||
_transcriptionRunning: boolean;
|
||||
|
||||
/**
|
||||
* CSS classes object (web only).
|
||||
*/
|
||||
classes?: Partial<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* The redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Whether to show file recordings service, even if integrations
|
||||
* are enabled.
|
||||
*/
|
||||
fileRecordingsServiceEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the possibility to share file recording with other people (e.g. Meeting participants), based on
|
||||
* the actual implementation on the backend.
|
||||
*/
|
||||
fileRecordingsServiceSharingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* If true the content related to the integrations will be shown.
|
||||
*/
|
||||
integrationsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* <tt>true</tt> if we have valid oauth token.
|
||||
*/
|
||||
isTokenValid: boolean;
|
||||
|
||||
/**
|
||||
* <tt>true</tt> if we are in process of validating the oauth token.
|
||||
*/
|
||||
isValidating: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the current meeting is a vpaas one.
|
||||
*/
|
||||
isVpaas: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not we should only record the local streams.
|
||||
*/
|
||||
localRecordingOnlySelf?: boolean;
|
||||
|
||||
/**
|
||||
* Callback to change the local recording only self setting.
|
||||
*/
|
||||
onLocalRecordingSelfChange?: () => void;
|
||||
|
||||
/**
|
||||
* The function will be called when the selected recording service changes.
|
||||
*/
|
||||
onRecordingServiceChange: (service: string) => void;
|
||||
|
||||
/**
|
||||
* Callback to be invoked on sharing setting change.
|
||||
*/
|
||||
onSharingSettingChanged: () => void;
|
||||
|
||||
/**
|
||||
* Starts every service which is not running.
|
||||
*/
|
||||
onStartBoth: () => void;
|
||||
|
||||
/**
|
||||
* Starts the audio & video recording.
|
||||
*/
|
||||
onStartRecording: () => void;
|
||||
|
||||
/**
|
||||
* Starts the transcription.
|
||||
*/
|
||||
onStartTranscription: () => void;
|
||||
|
||||
/**
|
||||
* Stops every running service.
|
||||
*/
|
||||
onStopBoth: () => void;
|
||||
|
||||
/**
|
||||
* Stops the audio & video recording.
|
||||
*/
|
||||
onStopRecording: () => void;
|
||||
|
||||
/**
|
||||
* Stops the transcription.
|
||||
*/
|
||||
onStopTranscription: () => void;
|
||||
|
||||
/**
|
||||
* Callback to be invoked when the transcription language changes.
|
||||
*/
|
||||
onSubtitlesLanguageChange: (language: string) => void;
|
||||
|
||||
/**
|
||||
* When false, the recording section starts collapsed and the
|
||||
* transcription section starts expanded, putting the focus on
|
||||
* transcription (e.g. subtitles/nudge flows).
|
||||
*/
|
||||
recordAudioAndVideo?: boolean;
|
||||
|
||||
/**
|
||||
* When true, audio/video recording is specifically in progress.
|
||||
*/
|
||||
recordingRunning: boolean;
|
||||
|
||||
/**
|
||||
* The ("translation-languages:" prefixed) language the transcription will
|
||||
* be saved in.
|
||||
*/
|
||||
selectedLanguage: string | null;
|
||||
|
||||
/**
|
||||
* The currently selected recording service of type: RECORDING_TYPES.
|
||||
*/
|
||||
selectedRecordingService: string | null;
|
||||
|
||||
/**
|
||||
* Boolean to set file recording sharing on or off.
|
||||
*/
|
||||
sharingSetting: boolean;
|
||||
|
||||
/**
|
||||
* Number of MiB of available space in user's Dropbox account.
|
||||
*/
|
||||
spaceLeft?: number;
|
||||
|
||||
/**
|
||||
* Whether the start recording action is currently unavailable (no usable
|
||||
* service selected).
|
||||
*/
|
||||
startRecordingDisabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether transcription is currently running (from the dialog's point of
|
||||
* view, i.e. Only when the local participant can control it).
|
||||
*/
|
||||
transcriptionRunning: boolean;
|
||||
|
||||
/**
|
||||
* The display name of the user's Dropbox account.
|
||||
*/
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
|
||||
/**
|
||||
* Whether the language list is expanded (used by the native inline
|
||||
* picker; the web renders a combobox instead).
|
||||
*/
|
||||
showLanguageList: boolean;
|
||||
|
||||
/**
|
||||
* Whether the recording section options are expanded.
|
||||
*/
|
||||
showRecordingOptions: boolean;
|
||||
|
||||
/**
|
||||
* Whether the transcription section options are expanded.
|
||||
*/
|
||||
showTranscriptionOptions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Component for the recording & transcription dialog content: two
|
||||
* sections (audio & video recording, transcription), each with its own
|
||||
* start/stop button and a collapsible options area, plus footer buttons
|
||||
* acting on both services at once.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class AbstractStartRecordingDialogContent extends Component<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new {@code AbstractStartRecordingDialogContent} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler; it bounds once for every instance.
|
||||
this._onSignIn = this._onSignIn.bind(this);
|
||||
this._onSignOut = this._onSignOut.bind(this);
|
||||
this._onRecordingServiceChange = this._onRecordingServiceChange.bind(this);
|
||||
this._onFollowMeRecorderChange = this._onFollowMeRecorderChange.bind(this);
|
||||
this._onToggleRecordingOptions = this._onToggleRecordingOptions.bind(this);
|
||||
this._onToggleTranscriptionOptions = this._onToggleTranscriptionOptions.bind(this);
|
||||
this._onToggleLanguageList = this._onToggleLanguageList.bind(this);
|
||||
|
||||
// Both sections start collapsed, except transcription options are pre-expanded when a
|
||||
// caller (e.g. the transcription nudge) explicitly asks to focus transcription instead
|
||||
// of recording by passing recordAudioAndVideo: false.
|
||||
this.state = {
|
||||
showLanguageList: false,
|
||||
showRecordingOptions: false,
|
||||
showTranscriptionOptions: props.recordAudioAndVideo === false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#componentDidUpdate}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidUpdate(prevProps: IProps) {
|
||||
// Auto sign-out when the user chooses another recording service.
|
||||
if (prevProps.selectedRecordingService === RECORDING_TYPES.DROPBOX
|
||||
&& this.props.selectedRecordingService !== RECORDING_TYPES.DROPBOX && this.props.isTokenValid) {
|
||||
this._onSignOut();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the recording section options.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onToggleRecordingOptions() {
|
||||
this.setState({ showRecordingOptions: !this.state.showRecordingOptions });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the transcription section options.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onToggleTranscriptionOptions() {
|
||||
this.setState({ showTranscriptionOptions: !this.state.showTranscriptionOptions });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the inline language list.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onToggleLanguageList() {
|
||||
this.setState({ showLanguageList: !this.state.showLanguageList });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of recording services (RECORDING_TYPES values) the
|
||||
* participant can currently pick from.
|
||||
*
|
||||
* Cloud based services (Jitsi recording service, Dropbox) require the
|
||||
* recording JWT feature and are unavailable while a live stream runs
|
||||
* (both use Jibri). Dropbox is additionally unavailable while a session
|
||||
* is running (integrationsEnabled covers that).
|
||||
*
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
_getRecordingServiceOptions(): Array<string> {
|
||||
const {
|
||||
_isLiveStreamRunning,
|
||||
_localRecordingAvailable,
|
||||
_renderRecording,
|
||||
fileRecordingsServiceEnabled,
|
||||
integrationsEnabled
|
||||
} = this.props;
|
||||
const options = [];
|
||||
|
||||
if (_renderRecording && !_isLiveStreamRunning) {
|
||||
if (fileRecordingsServiceEnabled) {
|
||||
options.push(RECORDING_TYPES.JITSI_REC_SERVICE);
|
||||
}
|
||||
if (integrationsEnabled) {
|
||||
options.push(RECORDING_TYPES.DROPBOX);
|
||||
}
|
||||
}
|
||||
if (_localRecordingAvailable) {
|
||||
options.push(RECORDING_TYPES.LOCAL);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the audio & video recording section should be rendered.
|
||||
*
|
||||
* @param {Array<string>} [options] - The result of {@code _getRecordingServiceOptions()},
|
||||
* when the caller already computed it (avoids recomputing it a second time).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldRenderRecordingSection(options: Array<string> = this._getRecordingServiceOptions()) {
|
||||
return this.props.recordingRunning || options.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the transcription section should be rendered.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldRenderTranscriptionSection() {
|
||||
return this._canStartTranscribing() || this.props.transcriptionRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the footer buttons (start both / stop both) should be rendered.
|
||||
* They only make sense when the participant can act on both services.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldRenderFooter() {
|
||||
return this._shouldRenderRecordingSection() && this._shouldRenderTranscriptionSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the file sharing content should be rendered or not.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldRenderFileSharingContent() {
|
||||
const {
|
||||
fileRecordingsServiceEnabled,
|
||||
fileRecordingsServiceSharingEnabled,
|
||||
isVpaas,
|
||||
selectedRecordingService
|
||||
} = this.props;
|
||||
|
||||
if (!fileRecordingsServiceEnabled
|
||||
|| !fileRecordingsServiceSharingEnabled
|
||||
|| isVpaas
|
||||
|| selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recorder follow me option should be rendered: moderators
|
||||
* only, and only for the cloud based services which spawn a recorder
|
||||
* participant that can follow (local recordings record the own view).
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldRenderFollowMeRecorder() {
|
||||
const { _isModerator, selectedRecordingService } = this.props;
|
||||
|
||||
return _isModerator
|
||||
&& (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
|
||||
|| selectedRecordingService === RECORDING_TYPES.DROPBOX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the save transcription content should be rendered or not.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_canStartTranscribing() {
|
||||
return this.props._canStartTranscribing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translation key labelling the given recording service in
|
||||
* the storage service picker.
|
||||
*
|
||||
* @param {string} service - One of the RECORDING_TYPES values.
|
||||
* @returns {string}
|
||||
*/
|
||||
_getServiceLabelKey(service: string | null) {
|
||||
switch (service) {
|
||||
case RECORDING_TYPES.JITSI_REC_SERVICE:
|
||||
return this.props.isVpaas ? 'recording.serviceDescriptionCloud' : 'recording.serviceName';
|
||||
case RECORDING_TYPES.DROPBOX:
|
||||
return 'recording.dropbox';
|
||||
case RECORDING_TYPES.LOCAL:
|
||||
return 'recording.localRecording';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the one line summary of the recording options, shown in the
|
||||
* collapsed accordion header: the selected storage service followed by
|
||||
* the state of its suboptions.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_getRecordingSummary() {
|
||||
const {
|
||||
isTokenValid,
|
||||
localRecordingOnlySelf,
|
||||
selectedRecordingService,
|
||||
sharingSetting,
|
||||
t,
|
||||
userName
|
||||
} = this.props;
|
||||
|
||||
if (!selectedRecordingService) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = [ t(this._getServiceLabelKey(selectedRecordingService)) ];
|
||||
|
||||
switch (selectedRecordingService) {
|
||||
case RECORDING_TYPES.JITSI_REC_SERVICE:
|
||||
if (this._shouldRenderFileSharingContent()) {
|
||||
parts.push(t(sharingSetting ? 'recording.linkShared' : 'recording.linkNotShared'));
|
||||
}
|
||||
break;
|
||||
case RECORDING_TYPES.DROPBOX:
|
||||
parts.push(isTokenValid ? t('recording.loggedIn', { userName }) : t('recording.notSignedIn'));
|
||||
break;
|
||||
case RECORDING_TYPES.LOCAL:
|
||||
if (localRecordingOnlySelf) {
|
||||
parts.push(t('recording.onlyRecordSelf'));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (this._shouldRenderFollowMeRecorder() && this._isFollowMeRecorderChecked()) {
|
||||
parts.push(t('settings.followMeRecorder'));
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the one line summary of the transcription options, shown in the
|
||||
* collapsed accordion header: the selected language.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_getTranscriptionSummary() {
|
||||
const { selectedLanguage, t } = this.props;
|
||||
|
||||
return selectedLanguage ? t(selectedLanguage) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for recording service selection changes.
|
||||
*
|
||||
* @param {string} service - The newly selected service.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onRecordingServiceChange(service: string) {
|
||||
const { isTokenValid, onRecordingServiceChange, selectedRecordingService } = this.props;
|
||||
|
||||
if (service === selectedRecordingService) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRecordingServiceChange(service);
|
||||
|
||||
if (service === RECORDING_TYPES.DROPBOX && !isTokenValid) {
|
||||
this._onSignIn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the recorder follow me setting.
|
||||
*
|
||||
* @param {boolean} enabled - The new value.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onFollowMeRecorderChange(enabled?: boolean) {
|
||||
const value = enabled ?? !this.props._followMeRecorderEnabled;
|
||||
|
||||
this.props.dispatch(setFollowMeRecorderExclusive(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recorder follow me option is checked.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isFollowMeRecorderChecked() {
|
||||
const { _followMeRecorderActive, _followMeRecorderEnabled } = this.props;
|
||||
|
||||
return _followMeRecorderEnabled && !_followMeRecorderActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recorder follow me option is disabled: another moderator
|
||||
* has one of the follow me modes active, or a recording is already in
|
||||
* progress — the recorder cannot pick up the change after it started.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isFollowMeRecorderDisabled() {
|
||||
const { _followMeActive, _followMeRecorderActive, recordingRunning } = this.props;
|
||||
|
||||
return _followMeActive || _followMeRecorderActive || recordingRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sings in a user.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSignIn() {
|
||||
sendAnalytics(createRecordingDialogEvent('start', 'signIn.button'));
|
||||
this.props.dispatch(authorizeDropbox());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sings out an user from dropbox.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSignOut() {
|
||||
sendAnalytics(createRecordingDialogEvent('start', 'signOut.button'));
|
||||
this.props.dispatch(updateDropboxToken());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function mapStateToProps(state: IReduxState) {
|
||||
const { localRecording, recordingService } = state['features/base/config'];
|
||||
const _localRecordingAvailable = !localRecording?.disable && supportsLocalRecording();
|
||||
const canManageRecordingOrTranscription
|
||||
= isLocalParticipantModerator(state) || hasRecordingOrTranscriptionFeature(state);
|
||||
const { followMeRecorderEnabled } = state['features/follow-me'];
|
||||
const subtitlesLanguage = state['features/subtitles']._language?.replace('translation-languages:', '');
|
||||
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
isVpaas: isVpaasMeeting(state),
|
||||
_availableLanguages: getAvailableSubtitlesLanguages(state, subtitlesLanguage)
|
||||
.map((lang: string) => `translation-languages:${lang}`),
|
||||
_canManageRecordingOrTranscription: canManageRecordingOrTranscription,
|
||||
_canStartTranscribing: canAddTranscriber(state),
|
||||
_followMeActive: isFollowMeActive(state),
|
||||
_followMeRecorderActive: isFollowMeRecorderActive(state),
|
||||
_followMeRecorderEnabled: Boolean(followMeRecorderEnabled),
|
||||
_isLiveStreamRunning: isLiveStreamingRunning(state),
|
||||
_hideStorageWarning: Boolean(recordingService?.hideStorageWarning),
|
||||
_isModerator: isLocalParticipantModerator(state),
|
||||
_renderRecording: isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false),
|
||||
_transcriptionRunning: canManageRecordingOrTranscription ? isRecorderTranscriptionsRunning(state) : false,
|
||||
_localRecordingAvailable,
|
||||
_localRecordingEnabled: !localRecording?.disable,
|
||||
_localRecordingRunning: Boolean(state['features/recording'].localRecordingRunning),
|
||||
_localRecordingSelfEnabled: !localRecording?.disableSelfRecording,
|
||||
_localRecordingNoNotification: !localRecording?.notifyAllParticipants,
|
||||
_styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent')
|
||||
};
|
||||
}
|
||||
|
||||
export default AbstractStartRecordingDialogContent;
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { createRecordingDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { setVideoMuted } from '../../../base/media/actions';
|
||||
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
|
||||
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
|
||||
import { setStopRecordingIntent, stopLocalVideoRecording } from '../../actions';
|
||||
import { RECORDING_METADATA_ID } from '../../constants';
|
||||
import { getActiveSession } from '../../functions';
|
||||
import { ISessionData } from '../../reducer';
|
||||
|
||||
import LocalRecordingManager from './LocalRecordingManager';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link AbstractStopRecordingDialog}.
|
||||
*/
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The {@code JitsiConference} for the current conference.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* Whether subtitles should be displayed or not.
|
||||
*/
|
||||
_displaySubtitles?: boolean;
|
||||
|
||||
/**
|
||||
* The redux representation of the recording session to be stopped.
|
||||
*/
|
||||
_fileRecordingSession?: ISessionData;
|
||||
|
||||
/**
|
||||
* Whether the recording is a local recording or not.
|
||||
*/
|
||||
_localRecording: boolean;
|
||||
|
||||
/**
|
||||
* The selected language for subtitles.
|
||||
*/
|
||||
_subtitlesLanguage: string | null;
|
||||
|
||||
/**
|
||||
* Whether a transcription session is running.
|
||||
*/
|
||||
_transcriptionRunning: boolean;
|
||||
|
||||
/**
|
||||
* The redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* The user trying to stop the video while local recording is running.
|
||||
*/
|
||||
localRecordingVideoStop?: boolean;
|
||||
|
||||
/**
|
||||
* Controls which service is stopped: 'recording' stops the file recording
|
||||
* session, 'transcription' stops only transcription and leaves recording
|
||||
* running.
|
||||
*/
|
||||
stopMode: 'recording' | 'transcription';
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract React Component for getting confirmation to stop a file recording
|
||||
* session in progress.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
export default class AbstractStopRecordingDialog<P extends IProps>
|
||||
extends Component<P> {
|
||||
/**
|
||||
* Initializes a new {@code AbstrStopRecordingDialog} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: P) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
this._toggleScreenshotCapture = this._toggleScreenshotCapture.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the recording session.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} - True (to note that the modal should be closed).
|
||||
*/
|
||||
_onSubmit() {
|
||||
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
|
||||
|
||||
const {
|
||||
_conference,
|
||||
_displaySubtitles,
|
||||
_fileRecordingSession,
|
||||
_localRecording,
|
||||
_subtitlesLanguage,
|
||||
_transcriptionRunning,
|
||||
dispatch,
|
||||
localRecordingVideoStop,
|
||||
stopMode
|
||||
} = this.props;
|
||||
|
||||
const stoppingRecording = stopMode === 'recording';
|
||||
const stoppingTranscription = stopMode === 'transcription';
|
||||
|
||||
// Pre-seed stopRecordingIntent so the off-sound/notification coordinator
|
||||
// (maybeNotifyRecordingStop) knows what to wait for. Each button only
|
||||
// signals the service it is responsible for stopping.
|
||||
if (!_localRecording) {
|
||||
const recordingRunning = stoppingRecording && Boolean(_fileRecordingSession);
|
||||
const transcriptionRunning = stoppingTranscription && _transcriptionRunning;
|
||||
|
||||
if (recordingRunning || transcriptionRunning) {
|
||||
dispatch(setStopRecordingIntent({
|
||||
recording: recordingRunning,
|
||||
transcription: transcriptionRunning
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (stoppingRecording) {
|
||||
if (_localRecording) {
|
||||
dispatch(stopLocalVideoRecording());
|
||||
if (localRecordingVideoStop) {
|
||||
dispatch(setVideoMuted(true));
|
||||
}
|
||||
} else if (_fileRecordingSession) {
|
||||
_conference?.stopRecording(_fileRecordingSession.id);
|
||||
this._toggleScreenshotCapture();
|
||||
}
|
||||
|
||||
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isRecordingRequested: false
|
||||
});
|
||||
}
|
||||
|
||||
if (stoppingTranscription) {
|
||||
// TODO: this should be an action in transcribing. -saghul
|
||||
dispatch(
|
||||
setRequestingSubtitles(Boolean(_displaySubtitles), _displaySubtitles, _subtitlesLanguage, true));
|
||||
|
||||
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isTranscribingEnabled: false
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screenshot capture feature.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_toggleScreenshotCapture() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code StopRecordingDialog} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
_displaySubtitles,
|
||||
_language: _subtitlesLanguage
|
||||
} = state['features/subtitles'];
|
||||
|
||||
return {
|
||||
_conference: state['features/base/conference'].conference,
|
||||
_displaySubtitles,
|
||||
_fileRecordingSession:
|
||||
getActiveSession(state, JitsiRecordingConstants.mode.FILE),
|
||||
_localRecording: LocalRecordingManager.isRecordingLocally(),
|
||||
_subtitlesLanguage,
|
||||
_transcriptionRunning: isRecorderTranscriptionsRunning(state)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { IStore } from '../../../app/types';
|
||||
|
||||
interface ILocalRecordingManager {
|
||||
addAudioTrackToLocalRecording: (track: any) => void;
|
||||
isRecordingLocally: () => boolean;
|
||||
isSupported: () => boolean;
|
||||
selfRecording: {
|
||||
on: boolean;
|
||||
withVideo: boolean;
|
||||
};
|
||||
startLocalRecording: (store: IStore, onlySelf: boolean) => Promise<void>;
|
||||
stopLocalRecording: () => void;
|
||||
}
|
||||
|
||||
const LocalRecordingManager: ILocalRecordingManager = {
|
||||
selfRecording: {
|
||||
on: false,
|
||||
withVideo: false
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds audio track to the recording stream.
|
||||
*
|
||||
* @param {any} track - Track to be added,.
|
||||
* @returns {void}
|
||||
*/
|
||||
addAudioTrackToLocalRecording() { }, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Stops local recording.
|
||||
*
|
||||
* @returns {void}
|
||||
* */
|
||||
stopLocalRecording() { }, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Starts a local recording.
|
||||
*
|
||||
* @param {IStore} store - The Redux store.
|
||||
* @returns {void}
|
||||
*/
|
||||
async startLocalRecording() { }, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Whether or not local recording is supported.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not we're currently recording locally.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRecordingLocally() {
|
||||
return false;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default LocalRecordingManager;
|
||||
@@ -0,0 +1,395 @@
|
||||
// @ts-ignore
|
||||
import * as ebml from 'ts-ebml/dist/EBML.min.js';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
import { IStore } from '../../../app/types';
|
||||
import { getRoomName } from '../../../base/conference/functions';
|
||||
import { isMobileBrowser } from '../../../base/environment/utils';
|
||||
import { browser } from '../../../base/lib-jitsi-meet';
|
||||
import { MEDIA_TYPE } from '../../../base/media/constants';
|
||||
import { getLocalTrack, getTrackState } from '../../../base/tracks/functions';
|
||||
import { isEmbedded } from '../../../base/util/embedUtils';
|
||||
import { stopLocalVideoRecording } from '../../actions.any';
|
||||
import logger from '../../logger';
|
||||
|
||||
interface ISelfRecording {
|
||||
on: boolean;
|
||||
withVideo: boolean;
|
||||
}
|
||||
|
||||
interface ILocalRecordingManager {
|
||||
addAudioTrackToLocalRecording: (track: MediaStreamTrack) => void;
|
||||
audioContext: AudioContext | undefined;
|
||||
audioDestination: MediaStreamAudioDestinationNode | undefined;
|
||||
fileHandle: FileSystemFileHandle | undefined;
|
||||
firstChunk: Blob | undefined;
|
||||
getFilename: () => string;
|
||||
initializeAudioMixer: () => void;
|
||||
isRecordingLocally: () => boolean;
|
||||
isSupported: () => boolean;
|
||||
mediaType: string;
|
||||
mixAudioStream: (stream: MediaStream) => void;
|
||||
recorder: MediaRecorder | undefined;
|
||||
roomName: string;
|
||||
selfRecording: ISelfRecording;
|
||||
startLocalRecording: (store: IStore, onlySelf: boolean) => Promise<void>;
|
||||
startTime: number | undefined;
|
||||
stopLocalRecording: () => void;
|
||||
stream: MediaStream | undefined;
|
||||
writableStream: FileSystemWritableFileStream | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a lot of trial and error, this is the preferred media type for
|
||||
* local recording. It is the only one that works across all platforms, with the
|
||||
* only caveat being that the resulting file wouldn't be seekable.
|
||||
*
|
||||
* We solve that by fixing the first Blob in order to reserve the space for the
|
||||
* corrected metadata, and after the recording is done, we do it again, this time with
|
||||
* the real duration, and overwrite the first part of the file.
|
||||
*/
|
||||
const PREFERRED_MEDIA_TYPE = 'video/webm;codecs=vp8,opus';
|
||||
|
||||
const VIDEO_BIT_RATE = 2500000; // 2.5Mbps in bits
|
||||
|
||||
|
||||
const LocalRecordingManager: ILocalRecordingManager = {
|
||||
recorder: undefined,
|
||||
stream: undefined,
|
||||
audioContext: undefined,
|
||||
audioDestination: undefined,
|
||||
roomName: '',
|
||||
selfRecording: {
|
||||
on: false,
|
||||
withVideo: false
|
||||
},
|
||||
firstChunk: undefined,
|
||||
fileHandle: undefined,
|
||||
startTime: undefined,
|
||||
writableStream: undefined,
|
||||
|
||||
get mediaType() {
|
||||
if (this.selfRecording.on && !this.selfRecording.withVideo) {
|
||||
return 'audio/webm;';
|
||||
}
|
||||
|
||||
return PREFERRED_MEDIA_TYPE;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initializes audio context used for mixing audio tracks.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
initializeAudioMixer() {
|
||||
this.audioContext = new AudioContext();
|
||||
this.audioDestination = this.audioContext.createMediaStreamDestination();
|
||||
},
|
||||
|
||||
/**
|
||||
* Mixes multiple audio tracks to the destination media stream.
|
||||
*
|
||||
* @param {MediaStream} stream - The stream to mix.
|
||||
* @returns {void}
|
||||
* */
|
||||
mixAudioStream(stream) {
|
||||
if (stream.getAudioTracks().length > 0 && this.audioDestination) {
|
||||
this.audioContext?.createMediaStreamSource(stream).connect(this.audioDestination);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds audio track to the recording stream.
|
||||
*
|
||||
* @param {MediaStreamTrack} track - The track to be added.
|
||||
* @returns {void}
|
||||
*/
|
||||
addAudioTrackToLocalRecording(track) {
|
||||
if (this.selfRecording.on) {
|
||||
return;
|
||||
}
|
||||
if (track) {
|
||||
const stream = new MediaStream([ track ]);
|
||||
|
||||
this.mixAudioStream(stream);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a filename based ono the Jitsi room name in the URL and timestamp.
|
||||
*
|
||||
* @returns {string}
|
||||
* */
|
||||
getFilename() {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
|
||||
return `${this.roomName}_${timestamp}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Stops local recording.
|
||||
*
|
||||
* @returns {void}
|
||||
* */
|
||||
stopLocalRecording() {
|
||||
this.recorder?.stop();
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts a local recording.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @param {boolean} onlySelf - Whether to record only self streams.
|
||||
* @returns {void}
|
||||
*/
|
||||
async startLocalRecording(store, onlySelf) {
|
||||
const { dispatch, getState } = store;
|
||||
|
||||
this.roomName = getRoomName(getState()) ?? '';
|
||||
|
||||
// Get a handle to the file we are going to write.
|
||||
const options = {
|
||||
startIn: 'downloads',
|
||||
suggestedName: `${this.getFilename()}.webm`,
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
this.fileHandle = await window.showSaveFilePicker(options);
|
||||
this.writableStream = await this.fileHandle?.createWritable();
|
||||
|
||||
const supportsCaptureHandle = !isEmbedded();
|
||||
const tabId = uuidV4();
|
||||
|
||||
this.selfRecording.on = onlySelf;
|
||||
let gdmStream: MediaStream = new MediaStream();
|
||||
const tracks = getTrackState(getState());
|
||||
|
||||
if (onlySelf) {
|
||||
const audioTrack: MediaStreamTrack | undefined = getLocalTrack(tracks, MEDIA_TYPE.AUDIO)?.jitsiTrack?.track;
|
||||
let videoTrack: MediaStreamTrack | undefined = getLocalTrack(tracks, MEDIA_TYPE.VIDEO)?.jitsiTrack?.track;
|
||||
|
||||
if (videoTrack && videoTrack.readyState !== 'live') {
|
||||
videoTrack = undefined;
|
||||
}
|
||||
|
||||
if (!audioTrack && !videoTrack) {
|
||||
throw new Error('NoLocalStreams');
|
||||
}
|
||||
|
||||
this.selfRecording.withVideo = Boolean(videoTrack);
|
||||
const localTracks: MediaStreamTrack[] = [];
|
||||
|
||||
audioTrack && localTracks.push(audioTrack.clone());
|
||||
videoTrack && localTracks.push(videoTrack.clone());
|
||||
this.stream = new MediaStream(localTracks);
|
||||
} else {
|
||||
if (supportsCaptureHandle) {
|
||||
// @ts-ignore
|
||||
navigator.mediaDevices.setCaptureHandleConfig({
|
||||
handle: `JitsiMeet-${tabId}`,
|
||||
permittedOrigins: [ '*' ]
|
||||
});
|
||||
}
|
||||
|
||||
gdmStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
displaySurface: 'browser',
|
||||
frameRate: 30
|
||||
},
|
||||
audio: {
|
||||
autoGainControl: false,
|
||||
channelCount: 2,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
// @ts-ignore
|
||||
restrictOwnAudio: false,
|
||||
// @ts-ignore
|
||||
suppressLocalAudioPlayback: false,
|
||||
},
|
||||
// @ts-ignore
|
||||
preferCurrentTab: true,
|
||||
surfaceSwitching: 'exclude'
|
||||
});
|
||||
|
||||
const gdmVideoTrack = gdmStream.getVideoTracks()[0];
|
||||
|
||||
if (supportsCaptureHandle) {
|
||||
const isBrowser = gdmVideoTrack.getSettings().displaySurface === 'browser';
|
||||
const matchesHandle = (supportsCaptureHandle // @ts-ignore
|
||||
&& gdmVideoTrack.getCaptureHandle()?.handle === `JitsiMeet-${tabId}`);
|
||||
|
||||
if (!isBrowser || !matchesHandle) {
|
||||
gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||
throw new Error('WrongSurfaceSelected');
|
||||
}
|
||||
}
|
||||
|
||||
this.initializeAudioMixer();
|
||||
|
||||
const gdmAudioTrack = gdmStream.getAudioTracks()[0];
|
||||
|
||||
if (!gdmAudioTrack) {
|
||||
throw new Error('NoAudioTrackFound');
|
||||
}
|
||||
|
||||
this.addAudioTrackToLocalRecording(gdmAudioTrack);
|
||||
|
||||
const localAudioTrack = getLocalTrack(tracks, MEDIA_TYPE.AUDIO)?.jitsiTrack?.track;
|
||||
|
||||
if (localAudioTrack) {
|
||||
this.addAudioTrackToLocalRecording(localAudioTrack);
|
||||
}
|
||||
|
||||
this.stream = new MediaStream([
|
||||
...this.audioDestination?.stream.getAudioTracks() || [],
|
||||
gdmVideoTrack
|
||||
]);
|
||||
}
|
||||
|
||||
this.recorder = new MediaRecorder(this.stream, {
|
||||
// @ts-ignore
|
||||
audioBitrateMode: 'constant',
|
||||
mimeType: this.mediaType,
|
||||
videoBitsPerSecond: VIDEO_BIT_RATE
|
||||
});
|
||||
|
||||
// Serialize all chunk writes so the stop handler can await them before closing the
|
||||
// stream. Without this, the stop event fires concurrently with the last dataavailable
|
||||
// callback's async fixDuration call, and close() races the write — leaving an empty
|
||||
// file for recordings shorter than the 5-second timeslice.
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
this.recorder.addEventListener('dataavailable', e => {
|
||||
if (e.data && e.data.size > 0) {
|
||||
const chunk = e.data;
|
||||
|
||||
writeQueue = writeQueue.then(async () => {
|
||||
let data = chunk;
|
||||
|
||||
if (!this.firstChunk) {
|
||||
this.firstChunk = data = await fixDuration(chunk, 864000000); // Reserve 24h.
|
||||
}
|
||||
|
||||
await this.writableStream?.write(data);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.recorder.addEventListener('start', () => {
|
||||
this.startTime = Date.now();
|
||||
});
|
||||
|
||||
this.recorder.addEventListener('stop', async () => {
|
||||
const duration = Date.now() - this.startTime!;
|
||||
|
||||
this.stream?.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||
gdmStream?.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||
|
||||
this.recorder = undefined;
|
||||
this.audioContext?.close();
|
||||
this.audioContext = undefined;
|
||||
this.audioDestination = undefined;
|
||||
this.startTime = undefined;
|
||||
this.stream = undefined;
|
||||
this.selfRecording.on = false;
|
||||
this.selfRecording.withVideo = false;
|
||||
|
||||
// Wait for all pending dataavailable writes before closing the stream.
|
||||
await writeQueue;
|
||||
|
||||
if (this.writableStream) {
|
||||
try {
|
||||
if (this.firstChunk) {
|
||||
await this.writableStream.seek(0);
|
||||
await this.writableStream.write(await fixDuration(this.firstChunk!, duration));
|
||||
}
|
||||
await this.writableStream.close();
|
||||
} catch (e) {
|
||||
logger.error('Error while writing to the local recording file', e);
|
||||
} finally {
|
||||
this.firstChunk = undefined;
|
||||
this.fileHandle = undefined;
|
||||
this.writableStream = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!onlySelf) {
|
||||
gdmStream?.addEventListener('inactive', () => {
|
||||
dispatch(stopLocalVideoRecording());
|
||||
});
|
||||
|
||||
this.stream.addEventListener('inactive', () => {
|
||||
dispatch(stopLocalVideoRecording());
|
||||
});
|
||||
}
|
||||
|
||||
this.recorder.start(5000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not local recording is supported.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return browser.isChromiumBased()
|
||||
&& !browser.isElectron()
|
||||
&& !browser.isReactNative()
|
||||
&& !isMobileBrowser()
|
||||
|
||||
// @ts-expect-error
|
||||
&& Boolean(navigator.mediaDevices.setCaptureHandleConfig)
|
||||
// @ts-expect-error
|
||||
&& typeof window.showSaveFilePicker !== 'undefined'
|
||||
&& MediaRecorder.isTypeSupported(PREFERRED_MEDIA_TYPE);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not we're currently recording locally.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRecordingLocally() {
|
||||
return Boolean(this.recorder);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixes the duration in the WebM container metadata.
|
||||
* Note: cues are omitted.
|
||||
*
|
||||
* @param {Blob} data - The first Blob of WebM data.
|
||||
* @param {number} duration - Actual duration of the video in milliseconds.
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
async function fixDuration(data: Blob, duration: number): Promise<Blob> {
|
||||
const decoder = new ebml.Decoder();
|
||||
const reader = new ebml.Reader();
|
||||
|
||||
reader.logging = false;
|
||||
reader.drop_default_duration = false;
|
||||
|
||||
const dataBuf = await data.arrayBuffer();
|
||||
const elms = decoder.decode(dataBuf);
|
||||
|
||||
for (const elm of elms) {
|
||||
reader.read(elm);
|
||||
}
|
||||
reader.stop();
|
||||
|
||||
const newMetadataBuf = ebml.tools.makeMetadataSeekable(
|
||||
reader.metadatas,
|
||||
duration,
|
||||
[] // No cues
|
||||
);
|
||||
|
||||
const body = new Uint8Array(dataBuf).subarray(reader.metadataSize);
|
||||
|
||||
// @ts-ignore
|
||||
return new Blob([ newMetadataBuf, body ], { type: data.type });
|
||||
}
|
||||
|
||||
export default LocalRecordingManager;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as RecordingTranscriptionDialog } from './native/RecordingTranscriptionDialog';
|
||||
export { default as RecordingConsentDialog } from './native/RecordingConsentDialog';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as RecordingTranscriptionDialog } from './web/RecordingTranscriptionDialog';
|
||||
export { default as RecordingConsentDialog } from './web/RecordingConsentDialog';
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { IconHighlight } from '../../../../base/icons/svg';
|
||||
import Label from '../../../../base/label/components/native/Label';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme';
|
||||
import AbstractHighlightButton, {
|
||||
IProps as AbstractProps,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractHighlightButton';
|
||||
import styles from '../styles.native';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* Flag controlling visibility of the component.
|
||||
*/
|
||||
_visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* React {@code Component} responsible for displaying an action that
|
||||
* allows users to highlight a meeting moment.
|
||||
*/
|
||||
export class HighlightButton extends AbstractHighlightButton<IProps> {
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_disabled,
|
||||
_visible,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (!_visible || _disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
icon = { IconHighlight }
|
||||
iconColor = { BaseTheme.palette.field01 }
|
||||
style = { styles.highlightButton }
|
||||
text = { t('recording.highlight') }
|
||||
textStyle = { styles.highlightButtonText } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_abstractMapStateToProps)(HighlightButton));
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleProp, Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { batch, useDispatch } from 'react-redux';
|
||||
|
||||
import { hideSheet } from '../../../../base/dialog/actions';
|
||||
import BottomSheet from '../../../../base/dialog/components/native/BottomSheet';
|
||||
import Button from '../../../../base/ui/components/native/Button';
|
||||
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
|
||||
import { highlightMeetingMoment } from '../../../actions.any';
|
||||
import styles from '../styles.native';
|
||||
|
||||
const HighlightDialog = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const closeDialog = useCallback(() => dispatch(hideSheet()), [ dispatch ]);
|
||||
const highlightMoment = useCallback(() => {
|
||||
batch(() => {
|
||||
dispatch(highlightMeetingMoment());
|
||||
dispatch(hideSheet());
|
||||
});
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<BottomSheet>
|
||||
<View style = { styles.highlightDialog as StyleProp<ViewStyle> }>
|
||||
<Text style = { styles.highlightDialogHeading as StyleProp<TextStyle> }>
|
||||
{ `${t('recording.highlightMoment')}?` }
|
||||
</Text>
|
||||
<Text style = { styles.highlightDialogText as StyleProp<TextStyle> }>
|
||||
{ t('recording.highlightMomentSucessDescription') }
|
||||
</Text>
|
||||
<View style = { styles.highlightDialogButtonsContainer as StyleProp<ViewStyle> } >
|
||||
<Button
|
||||
accessibilityLabel = 'dialog.Cancel'
|
||||
labelKey = 'dialog.Cancel'
|
||||
onClick = { closeDialog }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<View style = { styles.highlightDialogButtonsSpace as StyleProp<ViewStyle> } />
|
||||
<Button
|
||||
accessibilityLabel = 'recording.highlight'
|
||||
labelKey = 'recording.highlight'
|
||||
onClick = { highlightMoment }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default HighlightDialog;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { IOS_RECORDING_ENABLED, RECORDING_ENABLED } from '../../../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../../../base/flags/functions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { navigate }
|
||||
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../../mobile/navigation/routes';
|
||||
import {
|
||||
IProps, _mapStateToProps as abstractStartLiveStreamDialogMapStateToProps
|
||||
} from '../../LiveStream/AbstractStartLiveStreamDialog';
|
||||
import AbstractRecordButton, {
|
||||
IProps as AbstractProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractRecordButton';
|
||||
|
||||
type Props = IProps & AbstractProps;
|
||||
|
||||
/**
|
||||
* Button for opening the recording/transcription management screen.
|
||||
* The screen handles both starting a new session and managing a running one.
|
||||
*/
|
||||
class RecordButton extends AbstractRecordButton<Props> {
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onHandleClick() {
|
||||
navigate(screen.conference.recording);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for this component.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} ownProps - The properties explicitly passed to the component
|
||||
* instance.
|
||||
* @private
|
||||
* @returns {Props}
|
||||
*/
|
||||
export function mapStateToProps(state: IReduxState) {
|
||||
const enabled = getFeatureFlag(state, RECORDING_ENABLED, true);
|
||||
const iosEnabled = Platform.OS !== 'ios' || getFeatureFlag(state, IOS_RECORDING_ENABLED, false);
|
||||
const abstractProps = _abstractMapStateToProps(state);
|
||||
|
||||
return {
|
||||
...abstractProps,
|
||||
...abstractStartLiveStreamDialogMapStateToProps(state),
|
||||
visible: Boolean(enabled && iosEnabled && abstractProps.visible)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(RecordButton));
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Dialog from 'react-native-dialog';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDialog';
|
||||
import { setAudioMuted, setAudioUnmutePermissions, setVideoMuted, setVideoUnmutePermissions } from '../../../../base/media/actions';
|
||||
import { VIDEO_MUTISM_AUTHORITY } from '../../../../base/media/constants';
|
||||
import Link from '../../../../base/react/components/native/Link';
|
||||
import { IRecordingConsentDialogProps } from '../../../reducer';
|
||||
import styles from '../styles.native';
|
||||
|
||||
/**
|
||||
* Component that renders the dialog for explicit consent for recordings.
|
||||
*
|
||||
* @param {IRecordingConsentDialogProps} props - The component props.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export default function RecordingConsentDialog({ audioWasMuted = false, videoWasMuted = false }: IRecordingConsentDialogProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { recordings } = useSelector((state: IReduxState) => state['features/base/config']);
|
||||
const { consentLearnMoreLink } = recordings ?? {};
|
||||
|
||||
const consent = useCallback(() => {
|
||||
dispatch(setAudioUnmutePermissions(false, true));
|
||||
dispatch(setVideoUnmutePermissions(false, true));
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const consentAndUnmute = useCallback(() => {
|
||||
dispatch(setAudioUnmutePermissions(false, true));
|
||||
dispatch(setVideoUnmutePermissions(false, true));
|
||||
|
||||
// Restore to the mute state before consent was requested.
|
||||
dispatch(setAudioMuted(audioWasMuted, false));
|
||||
dispatch(setVideoMuted(videoWasMuted, VIDEO_MUTISM_AUTHORITY.USER, false));
|
||||
|
||||
return true;
|
||||
}, [ audioWasMuted, videoWasMuted ]);
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
backLabel = { 'dialog.UnderstandAndUnmute' }
|
||||
confirmLabel = { 'dialog.Understand' }
|
||||
isBackHidden = { false }
|
||||
isCancelHidden = { true }
|
||||
onBack = { consentAndUnmute }
|
||||
onSubmit = { consent }
|
||||
title = { 'dialog.recordingInProgressTitle' }
|
||||
verticalButtons = { true }>
|
||||
<Dialog.Description>
|
||||
{t('dialog.recordingInProgressDescription')}
|
||||
{consentLearnMoreLink && (
|
||||
<Link
|
||||
style = { styles.learnMoreLink }
|
||||
url = { consentLearnMoreLink }>
|
||||
{` (${t('dialog.learnMore')})`}
|
||||
</Link>
|
||||
)}
|
||||
{'.'}
|
||||
</Dialog.Description>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { TouchableOpacity, View, ViewStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconCheck } from '../../../../base/icons/svg';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Whether the row can be selected.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* The already translated label of the row.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Callback invoked with the row value on selection.
|
||||
*/
|
||||
onSelect: (value: string) => void;
|
||||
|
||||
/**
|
||||
* Whether the row is the selected one.
|
||||
*/
|
||||
selected: boolean;
|
||||
|
||||
/**
|
||||
* The value the row represents.
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
paddingVertical: BaseTheme.spacing[2]
|
||||
} as ViewStyle,
|
||||
|
||||
iconWrapper: {
|
||||
alignItems: 'center',
|
||||
width: BaseTheme.spacing[5]
|
||||
} as ViewStyle,
|
||||
|
||||
label: {
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
labelSelected: {
|
||||
fontWeight: 'bold' as const
|
||||
},
|
||||
|
||||
disabled: {
|
||||
opacity: 0.5
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A selectable row of the inline pickers (storage service, transcription
|
||||
* language) used by the native recording dialog.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const RecordingOptionRow = ({ disabled, label, onSelect, selected, value }: IProps) => {
|
||||
const onPress = useCallback(() => onSelect(value), [ onSelect, value ]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel = { label }
|
||||
accessibilityRole = 'button'
|
||||
accessibilityState = {{ disabled: Boolean(disabled), selected }}
|
||||
disabled = { disabled }
|
||||
onPress = { onPress }
|
||||
style = { [ styles.row, disabled && styles.disabled ] }>
|
||||
<View style = { styles.iconWrapper }>
|
||||
{ selected && <Icon
|
||||
size = { 20 }
|
||||
src = { IconCheck } /> }
|
||||
</View>
|
||||
<Text style = { [ styles.label, selected && styles.labelSelected ] }>
|
||||
{ label }
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingOptionRow;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { ScrollView } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
|
||||
import { goBack } from
|
||||
'../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import AbstractStartRecordingDialog, {
|
||||
mapStateToProps as abstractMapStateToProps
|
||||
} from '../AbstractStartRecordingDialog';
|
||||
import styles from '../styles.native';
|
||||
|
||||
import StartRecordingDialogContent from './StartRecordingDialogContent';
|
||||
|
||||
|
||||
/**
|
||||
* React Component for managing a recording/transcription session on native.
|
||||
* Each section (audio & video recording, transcription) has its own
|
||||
* start/stop button which applies the action immediately and navigates back.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class RecordingTranscriptionDialog extends AbstractStartRecordingDialog {
|
||||
|
||||
/**
|
||||
* Dismisses the screen by navigating back.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override _dismiss() {
|
||||
goBack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_fileRecordingsServiceEnabled,
|
||||
_fileRecordingsServiceSharingEnabled,
|
||||
_recordingRunning,
|
||||
_transcriptionRunning,
|
||||
recordAudioAndVideo
|
||||
} = this.props;
|
||||
const {
|
||||
isTokenValid,
|
||||
isValidating,
|
||||
localRecordingOnlySelf,
|
||||
selectedLanguage,
|
||||
selectedRecordingService,
|
||||
sharingEnabled,
|
||||
spaceLeft,
|
||||
userName
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<JitsiScreen style = { styles.startRecodingContainer }>
|
||||
<ScrollView>
|
||||
<StartRecordingDialogContent
|
||||
fileRecordingsServiceEnabled = { _fileRecordingsServiceEnabled }
|
||||
fileRecordingsServiceSharingEnabled = { _fileRecordingsServiceSharingEnabled }
|
||||
integrationsEnabled = { this._areIntegrationsEnabled() }
|
||||
isTokenValid = { isTokenValid }
|
||||
isValidating = { isValidating }
|
||||
localRecordingOnlySelf = { localRecordingOnlySelf }
|
||||
onLocalRecordingSelfChange = { this._onLocalRecordingSelfChange }
|
||||
onRecordingServiceChange = { this._onSelectedRecordingServiceChanged }
|
||||
onSharingSettingChanged = { this._onSharingSettingChanged }
|
||||
onStartBoth = { this._onStartBothPress }
|
||||
onStartRecording = { this._onStartRecordingPress }
|
||||
onStartTranscription = { this._onStartTranscriptionPress }
|
||||
onStopBoth = { this._onStopBothPress }
|
||||
onStopRecording = { this._onStopRecordingPress }
|
||||
onStopTranscription = { this._onStopTranscriptionPress }
|
||||
onSubtitlesLanguageChange = { this._onSubtitlesLanguageChanged }
|
||||
recordAudioAndVideo = { recordAudioAndVideo }
|
||||
recordingRunning = { Boolean(_recordingRunning) }
|
||||
selectedLanguage = { selectedLanguage }
|
||||
selectedRecordingService = { selectedRecordingService }
|
||||
sharingSetting = { sharingEnabled }
|
||||
spaceLeft = { spaceLeft }
|
||||
startRecordingDisabled = { this._isStartRecordingDisabled() }
|
||||
transcriptionRunning = { Boolean(_transcriptionRunning) }
|
||||
userName = { userName } />
|
||||
</ScrollView>
|
||||
</JitsiScreen>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps redux state to component props, bridging the navigation route params
|
||||
* (native-only) into the shared {@code recordAudioAndVideo} prop.
|
||||
*
|
||||
* @param {Object} state - Redux state.
|
||||
* @param {any} ownProps - Component's own props. Kept as `any`: this component is registered
|
||||
* directly as a react-navigation Screen (see ConferenceNavigationContainer.tsx), whose generic
|
||||
* `route`/`navigation` prop types don't compose with a narrower own-props type here without
|
||||
* modeling the whole navigation ParamList, which AbstractStartRecordingDialog's own
|
||||
* mapStateToProps(_ownProps: any) already opts out of for the same reason.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
return {
|
||||
...abstractMapStateToProps(state, {
|
||||
...ownProps,
|
||||
recordAudioAndVideo: ownProps.recordAudioAndVideo ?? ownProps.route?.params?.recordAudioAndVideo
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(RecordingTranscriptionDialog));
|
||||
@@ -0,0 +1,532 @@
|
||||
import React from 'react';
|
||||
import { Image, TouchableOpacity, View } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconArrowDown, IconArrowRight } from '../../../../base/icons/svg';
|
||||
import LoadingIndicator from '../../../../base/react/components/native/LoadingIndicator';
|
||||
import Button from '../../../../base/ui/components/native/Button';
|
||||
import Switch from '../../../../base/ui/components/native/Switch';
|
||||
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
|
||||
import LanguageListItem from '../../../../subtitles/components/native/LanguageListItem';
|
||||
import { RECORDING_TYPES } from '../../../constants';
|
||||
import { getRecordingDurationEstimation } from '../../../functions';
|
||||
import AbstractStartRecordingDialogContent, { IProps, mapStateToProps } from '../AbstractStartRecordingDialogContent';
|
||||
import {
|
||||
ICON_INFO,
|
||||
ICON_USERS
|
||||
} from '../styles.native';
|
||||
|
||||
import RecordingOptionRow from './RecordingOptionRow';
|
||||
|
||||
|
||||
/**
|
||||
* The recording & transcription dialog content for the mobile application.
|
||||
*/
|
||||
class StartRecordingDialogContent extends AbstractStartRecordingDialogContent {
|
||||
/**
|
||||
* Initializes a new {@code StartRecordingDialogContent} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onLanguageSelected = this._onLanguageSelected.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a language selection from the inline language list: applies it
|
||||
* and collapses the list.
|
||||
*
|
||||
* @param {string} language - The selected ("translation-languages:"
|
||||
* prefixed) language.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLanguageSelected(language: string) {
|
||||
this.props.onSubtitlesLanguageChange(language);
|
||||
this.setState({ showLanguageList: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the component.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
override render() {
|
||||
const { _styles: styles } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.container }>
|
||||
{ this._renderRecordingSection() }
|
||||
{ this._renderTranscriptionSection() }
|
||||
{ this._renderFooter() }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a section header: title and the start/stop button of the
|
||||
* section's service.
|
||||
*
|
||||
* @param {Object} options - Rendering options.
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderSectionHeader({ onButtonPress, running, startDisabled, startLabelKey, stopLabelKey, titleKey }: {
|
||||
onButtonPress: () => void;
|
||||
running: boolean;
|
||||
startDisabled?: boolean;
|
||||
startLabelKey: string;
|
||||
stopLabelKey: string;
|
||||
titleKey: string;
|
||||
}) {
|
||||
const { _dialogStyles, _styles: styles, t } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.header }>
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.title
|
||||
}}>
|
||||
{ t(titleKey) }
|
||||
</Text>
|
||||
<Button
|
||||
accessibilityLabel = { running ? stopLabelKey : startLabelKey }
|
||||
disabled = { !running && startDisabled }
|
||||
labelKey = { running ? 'dialog.stop' : 'dialog.start' }
|
||||
onClick = { onButtonPress }
|
||||
type = { running ? BUTTON_TYPES.DESTRUCTIVE : BUTTON_TYPES.PRIMARY } />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the accordion wrapping a section's options: a header with the
|
||||
* options title, a one line summary of the current choices and a chevron,
|
||||
* followed by the options themselves when expanded.
|
||||
*
|
||||
* @param {Object} options - Rendering options.
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderAccordion({ body, expanded, onToggle, summary, titleKey }: {
|
||||
body: React.ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
summary: string;
|
||||
titleKey: string;
|
||||
}) {
|
||||
const { _styles: styles, t } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.accordion }>
|
||||
<TouchableOpacity
|
||||
accessibilityLabel = { t(titleKey) }
|
||||
accessibilityRole = 'button'
|
||||
accessibilityState = {{ expanded }}
|
||||
onPress = { onToggle }
|
||||
style = { styles.accordionHeader }>
|
||||
<View style = { styles.accordionText }>
|
||||
<Text style = { styles.accordionTitle }>
|
||||
{ t(titleKey) }
|
||||
</Text>
|
||||
{ !expanded && (
|
||||
<Text
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.accordionSummary }>
|
||||
{ summary }
|
||||
</Text>
|
||||
) }
|
||||
</View>
|
||||
<Icon
|
||||
size = { 18 }
|
||||
src = { IconArrowDown }
|
||||
style = { expanded ? styles.accordionChevronOpen : undefined } />
|
||||
</TouchableOpacity>
|
||||
{ expanded && (
|
||||
<View style = { styles.accordionBody }>
|
||||
{ body }
|
||||
</View>
|
||||
) }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the audio & video recording section.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderRecordingSection() {
|
||||
const recordingServiceOptions = this._getRecordingServiceOptions();
|
||||
|
||||
if (!this._shouldRenderRecordingSection(recordingServiceOptions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
_dialogStyles,
|
||||
_styles: styles,
|
||||
isValidating,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
recordingRunning,
|
||||
selectedRecordingService,
|
||||
startRecordingDisabled,
|
||||
t
|
||||
} = this.props;
|
||||
const { showRecordingOptions } = this.state;
|
||||
|
||||
return (
|
||||
<View style = { styles.section }>
|
||||
{ this._renderSectionHeader({
|
||||
onButtonPress: recordingRunning ? onStopRecording : onStartRecording,
|
||||
running: recordingRunning,
|
||||
startDisabled: startRecordingDisabled || isValidating,
|
||||
startLabelKey: 'dialog.startRecording',
|
||||
stopLabelKey: 'dialog.stopRecording',
|
||||
titleKey: 'recording.recordAudioAndVideo'
|
||||
}) }
|
||||
{ this._renderAccordion({
|
||||
body: (
|
||||
<>
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.pickerLabel
|
||||
}}>
|
||||
{ t('recording.storageLocation') }
|
||||
</Text>
|
||||
{ recordingServiceOptions.map(service => (
|
||||
<RecordingOptionRow
|
||||
disabled = { recordingRunning || isValidating }
|
||||
key = { service }
|
||||
label = { t(this._getServiceLabelKey(service)) }
|
||||
onSelect = { this._onRecordingServiceChange }
|
||||
selected = { selectedRecordingService === service }
|
||||
value = { service } />
|
||||
)) }
|
||||
{ selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
|
||||
&& this._renderJitsiServiceOptions() }
|
||||
{ selectedRecordingService === RECORDING_TYPES.DROPBOX
|
||||
&& this._renderDropboxOptions() }
|
||||
{ this._renderFollowMeRecorder() }
|
||||
</>
|
||||
),
|
||||
expanded: showRecordingOptions,
|
||||
onToggle: this._onToggleRecordingOptions,
|
||||
|
||||
// Only rendered by _renderAccordion while collapsed — skip computing it
|
||||
// (and its nested t() calls) while the section is expanded.
|
||||
summary: showRecordingOptions ? '' : this._getRecordingSummary(),
|
||||
titleKey: 'recording.recordingOptions'
|
||||
}) }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the options of the Jitsi recording service: the recording link
|
||||
* sharing switch and the vpaas storage warning.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderJitsiServiceOptions() {
|
||||
const {
|
||||
_dialogStyles,
|
||||
_styles: styles,
|
||||
isValidating,
|
||||
onSharingSettingChanged,
|
||||
recordingRunning,
|
||||
sharingSetting,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ this._shouldRenderFileSharingContent() && (
|
||||
<View
|
||||
key = 'fileSharingSetting'
|
||||
style = { styles.headerNested }>
|
||||
<Image
|
||||
source = { ICON_USERS }
|
||||
style = { styles.recordingIcon } />
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.optionLabel
|
||||
}}>
|
||||
{ t('recording.fileSharingdescription') }
|
||||
</Text>
|
||||
<Switch
|
||||
checked = { sharingSetting }
|
||||
disabled = { isValidating || recordingRunning }
|
||||
onChange = { onSharingSettingChanged }
|
||||
style = { styles.switch } />
|
||||
</View>
|
||||
) }
|
||||
{ this._renderUploadToTheCloudInfo() }
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the info in case recording is uploaded to the cloud.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderUploadToTheCloudInfo() {
|
||||
const {
|
||||
_dialogStyles,
|
||||
_hideStorageWarning,
|
||||
_styles: styles,
|
||||
isVpaas,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (!isVpaas || _hideStorageWarning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
key = 'cloudUploadInfo'
|
||||
style = { styles.headerInfo }>
|
||||
<Image
|
||||
source = { ICON_INFO }
|
||||
style = { styles.recordingInfoIcon } />
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.titleInfo
|
||||
}}>
|
||||
{ t('recording.serviceDescriptionCloudInfo') }
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Dropbox account area: sign in button, or account info plus
|
||||
* sign out button once authorized.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderDropboxOptions() {
|
||||
const { _dialogStyles, _styles: styles, isTokenValid, isValidating, spaceLeft, t, userName } = this.props;
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
<View style = { styles.loggedIn }>
|
||||
<LoadingIndicator size = 'small' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isTokenValid) {
|
||||
return (
|
||||
<View style = { styles.headerNested }>
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.optionLabel
|
||||
}}>
|
||||
{ t('recording.dropboxSignInPrompt') }
|
||||
</Text>
|
||||
<Button
|
||||
accessibilityLabel = 'recording.signIn'
|
||||
labelKey = 'recording.signIn'
|
||||
onClick = { this._onSignIn }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const duration = getRecordingDurationEstimation(spaceLeft);
|
||||
|
||||
return (
|
||||
<View style = { styles.headerNested }>
|
||||
<View style = { styles.loggedInInfo }>
|
||||
<Text
|
||||
style = { [
|
||||
styles.text,
|
||||
styles.recordingText
|
||||
] }>
|
||||
{ t('recording.loggedIn', { userName }) }
|
||||
</Text>
|
||||
<Text
|
||||
style = { [
|
||||
styles.text,
|
||||
styles.recordingText
|
||||
] }>
|
||||
{
|
||||
t('recording.availableSpace', {
|
||||
spaceLeft,
|
||||
duration
|
||||
})
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
accessibilityLabel = 'recording.signOut'
|
||||
labelKey = 'recording.signOut'
|
||||
onClick = { this._onSignOut }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the recorder follow me switch for cloud based recordings.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderFollowMeRecorder() {
|
||||
if (!this._shouldRenderFollowMeRecorder()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { _dialogStyles, _styles: styles, t } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.headerNested }>
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.optionLabel
|
||||
}}>
|
||||
{ t('settings.followMeRecorder') }
|
||||
</Text>
|
||||
<Switch
|
||||
checked = { this._isFollowMeRecorderChecked() }
|
||||
disabled = { this._isFollowMeRecorderDisabled() }
|
||||
onChange = { this._onFollowMeRecorderChange }
|
||||
style = { styles.switch } />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the transcription section with the inline language picker.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderTranscriptionSection() {
|
||||
if (!this._shouldRenderTranscriptionSection()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
_availableLanguages,
|
||||
_dialogStyles,
|
||||
_styles: styles,
|
||||
onStartTranscription,
|
||||
onStopTranscription,
|
||||
selectedLanguage,
|
||||
t,
|
||||
transcriptionRunning
|
||||
} = this.props;
|
||||
const { showLanguageList, showTranscriptionOptions } = this.state;
|
||||
|
||||
return (
|
||||
<View style = { styles.section }>
|
||||
{ this._renderSectionHeader({
|
||||
onButtonPress: transcriptionRunning ? onStopTranscription : onStartTranscription,
|
||||
running: transcriptionRunning,
|
||||
startLabelKey: 'dialog.startTranscription',
|
||||
stopLabelKey: 'dialog.stopTranscription',
|
||||
titleKey: 'recording.recordTranscription'
|
||||
}) }
|
||||
{ this._renderAccordion({
|
||||
body: (
|
||||
<>
|
||||
<View style = { styles.headerNested }>
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.pickerLabel
|
||||
}}>
|
||||
{ `${t('recording.transcriptionLanguage')}: ${t(selectedLanguage ?? '')}` }
|
||||
</Text>
|
||||
<Icon
|
||||
ariaPressed = { showLanguageList }
|
||||
onClick = { transcriptionRunning ? undefined : this._onToggleLanguageList }
|
||||
role = 'button'
|
||||
size = { 24 }
|
||||
src = { showLanguageList ? IconArrowDown : IconArrowRight } />
|
||||
</View>
|
||||
{ showLanguageList && !transcriptionRunning
|
||||
&& _availableLanguages.map((language: string) => (
|
||||
<LanguageListItem
|
||||
key = { language }
|
||||
lang = { language }
|
||||
onLanguageSelected = { this._onLanguageSelected }
|
||||
selected = { selectedLanguage === language } />
|
||||
)) }
|
||||
</>
|
||||
),
|
||||
expanded: showTranscriptionOptions,
|
||||
onToggle: this._onToggleTranscriptionOptions,
|
||||
|
||||
// Only rendered by _renderAccordion while collapsed — skip computing it while
|
||||
// the section is expanded.
|
||||
summary: showTranscriptionOptions ? '' : this._getTranscriptionSummary(),
|
||||
titleKey: 'recording.transcriptionOptions'
|
||||
}) }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the footer buttons acting on both services at once. When only
|
||||
* one service is running both buttons are shown: stop both stops the
|
||||
* running one, start both starts the missing one.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderFooter() {
|
||||
if (!this._shouldRenderFooter()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
_styles: styles,
|
||||
isValidating,
|
||||
onStartBoth,
|
||||
onStopBoth,
|
||||
recordingRunning,
|
||||
startRecordingDisabled,
|
||||
transcriptionRunning
|
||||
} = this.props;
|
||||
const showStopBoth = recordingRunning || transcriptionRunning;
|
||||
const showStartBoth = !recordingRunning || !transcriptionRunning;
|
||||
const startBothDisabled = !recordingRunning && (startRecordingDisabled || isValidating);
|
||||
|
||||
return (
|
||||
<View style = { styles.footer }>
|
||||
{ showStopBoth && (
|
||||
<Button
|
||||
accessibilityLabel = 'dialog.stopBoth'
|
||||
labelKey = 'dialog.stopBoth'
|
||||
onClick = { onStopBoth }
|
||||
style = { styles.footerButton }
|
||||
type = { BUTTON_TYPES.DESTRUCTIVE } />
|
||||
) }
|
||||
{ showStartBoth && (
|
||||
<Button
|
||||
accessibilityLabel = 'dialog.startBoth'
|
||||
disabled = { startBothDisabled }
|
||||
labelKey = 'dialog.startBoth'
|
||||
onClick = { onStartBoth }
|
||||
style = { styles.footerButton }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
) }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(StartRecordingDialogContent));
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDialog';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import AbstractStopRecordingDialog, {
|
||||
IProps,
|
||||
_mapStateToProps
|
||||
} from '../AbstractStopRecordingDialog';
|
||||
|
||||
/**
|
||||
* React Component for getting confirmation to stop a file recording session in
|
||||
* progress.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StopRecordingDialog extends AbstractStopRecordingDialog<IProps> {
|
||||
|
||||
/**
|
||||
* Implements {@code Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
descriptionKey = 'dialog.stopRecordingWarning'
|
||||
onSubmit = { this._onSubmit } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StopRecordingDialog));
|
||||
229
react/features/recording/components/Recording/styles.native.ts
Normal file
229
react/features/recording/components/Recording/styles.native.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import ColorSchemeRegistry from '../../../base/color-scheme/ColorSchemeRegistry';
|
||||
import { schemeColor } from '../../../base/color-scheme/functions';
|
||||
import { BoxModel } from '../../../base/styles/components/styles/BoxModel';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
export const ICON_INFO = require('../../../../../images/icon-info.png');
|
||||
export const ICON_USERS = require('../../../../../images/icon-users.png');
|
||||
/* eslint-enable @typescript-eslint/no-var-requires */
|
||||
|
||||
// XXX The "standard" {@code BoxModel.padding} has been deemed insufficient in
|
||||
// the special case(s) of the recording feature below.
|
||||
const _PADDING = BoxModel.padding * 1.5;
|
||||
|
||||
const header = {
|
||||
alignItems: 'center',
|
||||
flex: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: _PADDING,
|
||||
paddingTop: _PADDING
|
||||
};
|
||||
|
||||
const recordingIcon = {
|
||||
width: BaseTheme.spacing[4],
|
||||
height: BaseTheme.spacing[4]
|
||||
};
|
||||
|
||||
const title = {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'left',
|
||||
paddingLeft: BoxModel.padding
|
||||
};
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Container for the StartRecordingDialog screen.
|
||||
*/
|
||||
startRecodingContainer: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingTop: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
highlightButton: {
|
||||
backgroundColor: BaseTheme.palette.ui09,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: BaseTheme.spacing[0],
|
||||
marginBottom: BaseTheme.spacing[0],
|
||||
marginRight: BaseTheme.spacing[1]
|
||||
},
|
||||
highlightButtonText: {
|
||||
color: BaseTheme.palette.field01,
|
||||
paddingLeft: BaseTheme.spacing[2],
|
||||
...BaseTheme.typography.labelBold
|
||||
},
|
||||
highlightDialog: {
|
||||
paddingLeft: BaseTheme.spacing[3],
|
||||
paddingRight: BaseTheme.spacing[3],
|
||||
paddingTop: BaseTheme.spacing[4],
|
||||
paddingBottom: BaseTheme.spacing[7]
|
||||
},
|
||||
highlightDialogHeading: {
|
||||
...BaseTheme.typography.heading5,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginBottom: BaseTheme.spacing[3]
|
||||
},
|
||||
highlightDialogText: {
|
||||
...BaseTheme.typography.bodyLongRegularLarge,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginBottom: BaseTheme.spacing[5]
|
||||
},
|
||||
highlightDialogButtonsContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse'
|
||||
},
|
||||
highlightDialogButtonsSpace: {
|
||||
height: 16,
|
||||
width: '100%'
|
||||
},
|
||||
learnMoreLink: {
|
||||
color: BaseTheme.palette.link01,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Color schemed styles for the @{code StartRecordingDialogContent} component.
|
||||
*/
|
||||
ColorSchemeRegistry.register('StartRecordingDialogContent', {
|
||||
|
||||
container: {
|
||||
flex: 0,
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginHorizontal: BaseTheme.spacing[3],
|
||||
paddingVertical: _PADDING
|
||||
},
|
||||
|
||||
footerButton: {
|
||||
marginLeft: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
header: {
|
||||
...header,
|
||||
marginHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
headerNested: {
|
||||
...header,
|
||||
paddingBottom: 0
|
||||
},
|
||||
|
||||
headerInfo: {
|
||||
...header,
|
||||
backgroundColor: BaseTheme.palette.warning02,
|
||||
marginBottom: BaseTheme.spacing[4],
|
||||
paddingHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
loggedIn: {
|
||||
paddingHorizontal: _PADDING,
|
||||
paddingVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
loggedInInfo: {
|
||||
flex: 1,
|
||||
marginRight: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
optionLabel: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
marginRight: BaseTheme.spacing[2],
|
||||
textAlign: 'left'
|
||||
},
|
||||
|
||||
pickerLabel: {
|
||||
color: BaseTheme.palette.text02,
|
||||
flex: 1,
|
||||
textAlign: 'left'
|
||||
},
|
||||
|
||||
section: {
|
||||
flex: 0,
|
||||
flexDirection: 'column',
|
||||
paddingVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
accordion: {
|
||||
backgroundColor: BaseTheme.palette.ui02,
|
||||
borderColor: BaseTheme.palette.ui03,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1,
|
||||
marginHorizontal: BaseTheme.spacing[3],
|
||||
marginTop: BaseTheme.spacing[2],
|
||||
overflow: 'hidden'
|
||||
},
|
||||
|
||||
accordionHeader: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
padding: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
accordionText: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
marginRight: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
accordionTitle: {
|
||||
color: BaseTheme.palette.text01,
|
||||
fontWeight: '600'
|
||||
},
|
||||
|
||||
accordionSummary: {
|
||||
color: BaseTheme.palette.text02,
|
||||
fontSize: 12
|
||||
},
|
||||
|
||||
accordionChevronOpen: {
|
||||
transform: [ { rotate: '180deg' } ]
|
||||
},
|
||||
|
||||
accordionBody: {
|
||||
paddingBottom: BaseTheme.spacing[2],
|
||||
paddingHorizontal: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
recordingIcon: {
|
||||
...recordingIcon
|
||||
},
|
||||
|
||||
recordingInfoIcon: {
|
||||
...recordingIcon
|
||||
},
|
||||
|
||||
recordingText: {
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
switch: {
|
||||
color: BaseTheme.palette.ui10
|
||||
},
|
||||
|
||||
title: {
|
||||
...title
|
||||
},
|
||||
|
||||
titleInfo: {
|
||||
...title,
|
||||
color: BaseTheme.palette.ui01
|
||||
},
|
||||
|
||||
text: {
|
||||
color: schemeColor('text')
|
||||
}
|
||||
});
|
||||
220
react/features/recording/components/Recording/styles.web.ts
Normal file
220
react/features/recording/components/Recording/styles.web.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Theme } from '@mui/material';
|
||||
|
||||
export const ICON_INFO = 'images/icon-info.png';
|
||||
|
||||
export const ICON_USERS = 'images/icon-users.png';
|
||||
|
||||
/**
|
||||
* Creates the theme based styles of the recording & transcription dialog
|
||||
* content.
|
||||
*
|
||||
* @param {Theme} theme - The current theme.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export const startRecordingDialogStyles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
flex: 0,
|
||||
flexDirection: 'column' as const
|
||||
},
|
||||
|
||||
section: {
|
||||
padding: `${theme.spacing(2)} 0`
|
||||
},
|
||||
|
||||
// No overflow: hidden here — the dropdown menus must be able to
|
||||
// overflow the accordion; the header carries its own border radius.
|
||||
accordion: {
|
||||
background: theme.palette.ui02,
|
||||
border: `1px solid ${theme.palette.ui03}`,
|
||||
borderRadius: '4px',
|
||||
boxSizing: 'border-box' as const,
|
||||
marginTop: theme.spacing(2),
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
// Also resets the native button appearance — the header is a <button>
|
||||
// so it is keyboard operable out of the box.
|
||||
accordionHeader: {
|
||||
alignItems: 'center',
|
||||
appearance: 'none' as const,
|
||||
background: 'none',
|
||||
border: 0,
|
||||
borderRadius: '4px',
|
||||
boxSizing: 'border-box' as const,
|
||||
color: theme.palette.text01,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
font: 'inherit',
|
||||
gap: theme.spacing(2),
|
||||
padding: '10px 12px',
|
||||
textAlign: 'left' as const,
|
||||
width: '100%',
|
||||
|
||||
'&:hover': {
|
||||
background: theme.palette.ui03
|
||||
},
|
||||
|
||||
'&:focus-visible': {
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.selectFocus} inset`,
|
||||
outline: 0
|
||||
}
|
||||
},
|
||||
|
||||
accordionText: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column' as const,
|
||||
gap: '2px',
|
||||
minWidth: 0
|
||||
},
|
||||
|
||||
accordionTitle: {
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2
|
||||
},
|
||||
|
||||
accordionSummary: {
|
||||
color: theme.palette.text02,
|
||||
fontSize: '0.75rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const
|
||||
},
|
||||
|
||||
accordionChevron: {
|
||||
color: theme.palette.text02,
|
||||
display: 'flex',
|
||||
marginLeft: 'auto',
|
||||
transition: 'transform .16s'
|
||||
},
|
||||
|
||||
accordionChevronOpen: {
|
||||
transform: 'rotate(180deg)'
|
||||
},
|
||||
|
||||
accordionBody: {
|
||||
padding: '12px'
|
||||
},
|
||||
|
||||
authorizationPanel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
margin: `${theme.spacing(2)} 0 0`
|
||||
},
|
||||
|
||||
loggedInPanel: {
|
||||
padding: `${theme.spacing(2)} 0`
|
||||
},
|
||||
|
||||
footer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row' as const,
|
||||
justifyContent: 'flex-end',
|
||||
padding: '12px 0 4px',
|
||||
|
||||
'& button:not(:first-child)': {
|
||||
marginLeft: theme.spacing(2)
|
||||
}
|
||||
},
|
||||
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flex: 0,
|
||||
flexDirection: 'row' as const,
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
|
||||
headerSpaceTop: {
|
||||
marginTop: '10px'
|
||||
},
|
||||
|
||||
title: {
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
fontSize: '0.875rem',
|
||||
marginLeft: theme.spacing(3),
|
||||
maxWidth: '70%'
|
||||
},
|
||||
|
||||
titleNoMargin: {
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
maxWidth: '70%'
|
||||
},
|
||||
|
||||
optionLabel: {
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
fontSize: '0.875rem',
|
||||
maxWidth: '70%'
|
||||
},
|
||||
|
||||
switch: {
|
||||
marginLeft: 'auto'
|
||||
},
|
||||
|
||||
iconContainer: {
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex'
|
||||
},
|
||||
|
||||
fileSharingIconContainer: {
|
||||
backgroundColor: theme.palette.ui04,
|
||||
borderRadius: '4px',
|
||||
height: '40px',
|
||||
justifyContent: 'center',
|
||||
width: '42px'
|
||||
},
|
||||
|
||||
fileSharingIcon: {
|
||||
height: '18px',
|
||||
objectFit: 'contain' as const,
|
||||
width: '18px'
|
||||
},
|
||||
|
||||
info: {
|
||||
backgroundColor: theme.palette.warning02,
|
||||
color: theme.palette.uiBackground,
|
||||
display: 'inline-flex',
|
||||
margin: '16px 0 8px',
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
infoIcon: {
|
||||
alignSelf: 'center',
|
||||
height: '14px',
|
||||
margin: '0 24px 0 16px',
|
||||
objectFit: 'contain' as const,
|
||||
width: '14px'
|
||||
},
|
||||
|
||||
infoTitle: {
|
||||
display: 'inline-flex',
|
||||
fontSize: '0.875rem',
|
||||
width: '290px'
|
||||
},
|
||||
|
||||
localRecordingWarning: {
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.25rem',
|
||||
marginTop: theme.spacing(2),
|
||||
padding: '8px 16px'
|
||||
},
|
||||
|
||||
localRecordingWarningText: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
color: theme.palette.text01
|
||||
},
|
||||
|
||||
localRecordingWarningNotification: {
|
||||
backgroundColor: theme.palette.warning02,
|
||||
color: theme.palette.uiBackground
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconArrowDown } from '../../../../base/icons/svg';
|
||||
import ContextMenu from '../../../../base/ui/components/web/ContextMenu';
|
||||
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
|
||||
import ContextMenuItemGroup from '../../../../base/ui/components/web/ContextMenuItemGroup';
|
||||
|
||||
interface IOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Whether or not the dropdown is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Id of the trigger element.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Label to be displayed above the dropdown.
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* Change handler, invoked with the value of the picked option.
|
||||
*/
|
||||
onChange: (value: string) => void;
|
||||
|
||||
/**
|
||||
* The options of the dropdown.
|
||||
*/
|
||||
options: Array<IOption>;
|
||||
|
||||
/**
|
||||
* The value of the selected option.
|
||||
*/
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
interface IItemProps {
|
||||
label: string;
|
||||
onSelect: (value: string) => void;
|
||||
selected: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
label: {
|
||||
color: theme.palette.selectLabel,
|
||||
...theme.typography.bodyShortRegular,
|
||||
marginBottom: theme.spacing(2)
|
||||
},
|
||||
|
||||
dropdownContainer: {
|
||||
position: 'relative'
|
||||
},
|
||||
|
||||
trigger: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.selectBackground,
|
||||
border: 0,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
color: theme.palette.selectText,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
...theme.typography.bodyShortRegular,
|
||||
padding: '10px 16px',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
|
||||
'&:focus': {
|
||||
outline: 0,
|
||||
boxShadow: `0px 0px 0px 2px ${theme.palette.selectFocus}`
|
||||
},
|
||||
|
||||
'&:disabled': {
|
||||
color: theme.palette.selectDisabled,
|
||||
cursor: 'default'
|
||||
}
|
||||
},
|
||||
|
||||
triggerText: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
|
||||
chevron: {
|
||||
marginLeft: theme.spacing(2),
|
||||
transition: 'transform .16s'
|
||||
},
|
||||
|
||||
chevronOpen: {
|
||||
transform: 'rotate(180deg)'
|
||||
},
|
||||
|
||||
// The z-index must beat the Switch knob (zIndex 10) of the option rows
|
||||
// rendered under the dropdown.
|
||||
menuWrapper: {
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: `calc(100% + ${theme.spacing(1)})`,
|
||||
zIndex: 50
|
||||
},
|
||||
|
||||
menuWrapperUp: {
|
||||
bottom: `calc(100% + ${theme.spacing(1)})`,
|
||||
top: 'auto'
|
||||
},
|
||||
|
||||
// Neutralizes the ContextMenu default positioning (marginTop/right/top
|
||||
// target the participants pane) so the menu sits in the wrapper. The
|
||||
// :focus highlight makes the arrow key navigation visible — the items
|
||||
// only style the .focus-visible polyfill class, which programmatic
|
||||
// focus() calls never set.
|
||||
menu: {
|
||||
boxSizing: 'border-box',
|
||||
margin: 0,
|
||||
maxHeight: '250px',
|
||||
overflowY: 'auto',
|
||||
position: 'static',
|
||||
right: 'auto',
|
||||
width: '100%',
|
||||
|
||||
'& [role="menuitem"]:focus': {
|
||||
backgroundColor: theme.palette.overflowMenuItemHover,
|
||||
outline: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* An entry of the dropdown options list.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const DropdownSelectItem = ({ label, onSelect, selected, value }: IItemProps) => {
|
||||
const onClick = useCallback(() => onSelect(value), [ onSelect, value ]);
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
accessibilityLabel = { label }
|
||||
onClick = { onClick }
|
||||
role = 'menuitem'
|
||||
selected = { selected }
|
||||
text = { label } />
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A themed single choice dropdown: a select look-alike built out of
|
||||
* ContextMenu so the options list matches the app theme (the native <select>
|
||||
* popup cannot be styled). The menu is rendered right under the trigger,
|
||||
* matching its width.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const DropdownSelect = ({ disabled, id, label, onChange, options, value }: IProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const [ isOpen, setIsOpen ] = useState(false);
|
||||
const [ openUp, setOpenUp ] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const onToggle = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When opening, check whether the menu fits below the trigger within
|
||||
// the closest scroll container (the dialog content) and open upwards
|
||||
// otherwise — the menu would be clipped/scrolled out of view below.
|
||||
if (!isOpen && containerRef.current) {
|
||||
let clipBottom = window.innerHeight;
|
||||
let parent = containerRef.current.parentElement;
|
||||
|
||||
while (parent) {
|
||||
const { overflowY } = window.getComputedStyle(parent);
|
||||
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
clipBottom = parent.getBoundingClientRect().bottom;
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
const { bottom } = containerRef.current.getBoundingClientRect();
|
||||
const menuHeight = Math.min(250, (options.length * 40) + 16) + 8;
|
||||
|
||||
setOpenUp(clipBottom - bottom < menuHeight);
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
}, [ disabled, isOpen, options.length ]);
|
||||
const onSelect = useCallback((selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}, [ onChange ]);
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
() => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []), []);
|
||||
|
||||
// Opens the menu with the arrow keys per the menu button pattern; when it
|
||||
// is already open, moves the focus into the item list.
|
||||
const onTriggerKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (isOpen) {
|
||||
const items = getMenuItems();
|
||||
|
||||
(e.key === 'ArrowDown' ? items[0] : items[items.length - 1])?.focus();
|
||||
} else {
|
||||
onToggle();
|
||||
}
|
||||
}, [ getMenuItems, isOpen, onToggle ]);
|
||||
|
||||
// Arrow key navigation between the menu items, wrapping around the ends.
|
||||
const onMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const items = getMenuItems();
|
||||
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
items[(currentIndex + 1) % items.length]?.focus();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
items[currentIndex <= 0 ? items.length - 1 : currentIndex - 1]?.focus();
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
items[0]?.focus();
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
items[items.length - 1]?.focus();
|
||||
break;
|
||||
case 'Tab':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
}, [ getMenuItems ]);
|
||||
|
||||
// Move focus to the selected item when the menu opens.
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));
|
||||
const items = menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
||||
|
||||
items?.[Math.min(selectedIndex, items.length - 1)]?.focus();
|
||||
}
|
||||
}, [ isOpen ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onOutsideClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Capture phase so closing the menu does not also close the dialog.
|
||||
const onEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', onOutsideClick);
|
||||
window.addEventListener('keydown', onEscKey, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', onOutsideClick);
|
||||
window.removeEventListener('keydown', onEscKey, true);
|
||||
};
|
||||
}, [ isOpen ]);
|
||||
|
||||
// Fall back to the first option's label when the current value isn't among the options
|
||||
// (e.g. it just became unavailable while the dialog stayed open) — otherwise the trigger
|
||||
// would render blank instead of a usable service name.
|
||||
const selectedLabel = options.find(option => option.value === value)?.label ?? options[0]?.label ?? '';
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
{ label && (
|
||||
<label
|
||||
className = { classes.label }
|
||||
htmlFor = { id }>
|
||||
{ label }
|
||||
</label>
|
||||
) }
|
||||
<div
|
||||
className = { classes.dropdownContainer }
|
||||
ref = { containerRef }>
|
||||
<button
|
||||
aria-controls = { `${id}-menu` }
|
||||
aria-expanded = { isOpen }
|
||||
aria-haspopup = 'menu'
|
||||
className = { classes.trigger }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
onClick = { onToggle }
|
||||
onKeyDown = { onTriggerKeyDown }
|
||||
ref = { triggerRef }
|
||||
type = 'button'>
|
||||
<span className = { classes.triggerText }>
|
||||
{ selectedLabel }
|
||||
</span>
|
||||
<Icon
|
||||
className = { cx(classes.chevron, isOpen && classes.chevronOpen) }
|
||||
size = { 18 }
|
||||
src = { IconArrowDown } />
|
||||
</button>
|
||||
{ isOpen && (
|
||||
// The arrow key interactions of the menu are handled on the
|
||||
// wrapper; the items themselves take care of Enter/Space.
|
||||
<div
|
||||
className = { cx(classes.menuWrapper, openUp && classes.menuWrapperUp) }
|
||||
onKeyDown = { onMenuKeyDown }
|
||||
ref = { menuRef }>
|
||||
<ContextMenu
|
||||
accessibilityLabel = { label }
|
||||
className = { classes.menu }
|
||||
hidden = { false }
|
||||
id = { `${id}-menu` }
|
||||
role = 'menu'>
|
||||
<ContextMenuItemGroup>
|
||||
{ options.map(option => (
|
||||
<DropdownSelectItem
|
||||
key = { option.value }
|
||||
label = { option.label }
|
||||
onSelect = { onSelect }
|
||||
selected = { option.value === value }
|
||||
value = { option.value } />
|
||||
)) }
|
||||
</ContextMenuItemGroup>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownSelect;
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { IconHighlight } from '../../../../base/icons/svg';
|
||||
import { MEET_FEATURES } from '../../../../base/jwt/constants';
|
||||
import Label from '../../../../base/label/components/web/Label';
|
||||
import Tooltip from '../../../../base/tooltip/components/Tooltip';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme.web';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../../../jaas/actions';
|
||||
import RecordingTranscriptionDialog from '../../Recording/web/RecordingTranscriptionDialog';
|
||||
import AbstractHighlightButton, {
|
||||
IProps as AbstractProps,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractHighlightButton';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* The message to show within the label's tooltip.
|
||||
*/
|
||||
_tooltipKey: string;
|
||||
|
||||
/**
|
||||
* Flag controlling visibility of the component.
|
||||
*/
|
||||
_visible: boolean;
|
||||
|
||||
/**
|
||||
* An object containing the CSS classes.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link HighlightButton}.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* Whether the notification which prompts for starting recording is open is not.
|
||||
*/
|
||||
isNotificationOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the styles for the component.
|
||||
*
|
||||
* @param {Object} theme - The current UI theme.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
position: 'relative' as const
|
||||
},
|
||||
disabled: {
|
||||
background: theme.palette.recordingHighlightButtonDisabled
|
||||
},
|
||||
regular: {
|
||||
background: theme.palette.recordingHighlightButton
|
||||
},
|
||||
highlightNotification: {
|
||||
backgroundColor: theme.palette.recordingHighlightButton,
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0px 6px 20px rgba(0, 0, 0, 0.25)',
|
||||
boxSizing: 'border-box' as const,
|
||||
color: theme.palette.recordingNotificationText,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
left: '4px',
|
||||
padding: '16px',
|
||||
position: 'absolute' as const,
|
||||
top: '32px',
|
||||
width: 320
|
||||
},
|
||||
highlightNotificationButton: {
|
||||
color: theme.palette.recordingNotificationAction,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
marginTop: '8px'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} responsible for displaying an action that
|
||||
* allows users to highlight a meeting moment.
|
||||
*/
|
||||
export class HighlightButton extends AbstractHighlightButton<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new HighlightButton instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isNotificationOpen: false
|
||||
};
|
||||
|
||||
this._onOpenDialog = this._onOpenDialog.bind(this);
|
||||
this._onWindowClickListener = this._onWindowClickListener.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidMount() {
|
||||
window.addEventListener('click', this._onWindowClickListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentWillUnmount()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentWillUnmount() {
|
||||
window.removeEventListener('click', this._onWindowClickListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the start recording button.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOpenDialog() {
|
||||
const { dispatch } = this.props;
|
||||
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
|
||||
|
||||
if (!dialogShown) {
|
||||
dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the highlight button.
|
||||
*
|
||||
* @override
|
||||
* @param {Event} e - The click event.
|
||||
* @returns {void}
|
||||
*/
|
||||
override _onClick(e?: React.MouseEvent) {
|
||||
e?.stopPropagation();
|
||||
|
||||
const { _disabled } = this.props;
|
||||
|
||||
if (_disabled) {
|
||||
this.setState({
|
||||
isNotificationOpen: true
|
||||
});
|
||||
} else {
|
||||
super._onClick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Window click event listener.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onWindowClickListener() {
|
||||
this.setState({
|
||||
isNotificationOpen: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_disabled,
|
||||
_visible,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
if (!_visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className = _disabled ? classes.disabled : classes.regular;
|
||||
const tooltipKey = _disabled ? 'recording.highlightMomentDisabled' : 'recording.highlightMoment';
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<Tooltip
|
||||
content = { t(tooltipKey) }
|
||||
position = { 'bottom' }>
|
||||
<Label
|
||||
className = { className }
|
||||
icon = { IconHighlight }
|
||||
iconColor = { _disabled ? BaseTheme.palette.recordingHighlightButtonIconDisabled : BaseTheme.palette.recordingHighlightButtonIcon }
|
||||
id = 'highlightMeetingLabel'
|
||||
onClick = { this._onClick } />
|
||||
</Tooltip>
|
||||
{this.state.isNotificationOpen && (
|
||||
<div className = { classes.highlightNotification }>
|
||||
{t('recording.highlightMomentDisabled')}
|
||||
<div
|
||||
className = { classes.highlightNotificationButton }
|
||||
onClick = { this._onOpenDialog }>
|
||||
{t('localRecording.start')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(connect(_abstractMapStateToProps)(HighlightButton)), styles);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { isRecordingRunning } from '../../../functions';
|
||||
import AbstractRecordButton, {
|
||||
IProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractRecordButton';
|
||||
|
||||
import RecordingTranscriptionDialog from './RecordingTranscriptionDialog';
|
||||
|
||||
|
||||
/**
|
||||
* Button for opening the unified recording & transcription management dialog.
|
||||
*/
|
||||
class RecordingButton extends AbstractRecordButton<IProps> {
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _onHandleClick() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code RecordButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _fileRecordingsDisabledTooltipKey: ?string,
|
||||
* _isRecordingRunning: boolean,
|
||||
* _disabled: boolean,
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const abstractProps = _abstractMapStateToProps(state);
|
||||
const { toolbarButtons } = state['features/toolbox'];
|
||||
const visible = Boolean(toolbarButtons?.includes('recording') && abstractProps.visible);
|
||||
|
||||
return {
|
||||
...abstractProps,
|
||||
_isRecordingRunning: isRecordingRunning(state),
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(RecordingButton));
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import { grantRecordingConsent, grantRecordingConsentAndUnmute } from '../../../actions.web';
|
||||
|
||||
|
||||
/**
|
||||
* Component that renders the dialog for explicit consent for recordings.
|
||||
* The prejoin mute state is read from Redux by the action creator.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export default function RecordingConsentDialog() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const { recordings } = useSelector((state: IReduxState) => state['features/base/config']);
|
||||
const { consentLearnMoreLink } = recordings ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
APP.API.notifyRecordingConsentDialogOpen(true);
|
||||
|
||||
return () => {
|
||||
APP.API.notifyRecordingConsentDialogOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const consent = useCallback(() => {
|
||||
dispatch(grantRecordingConsent());
|
||||
}, []);
|
||||
|
||||
const consentAndUnmute = useCallback(() => {
|
||||
dispatch(grantRecordingConsentAndUnmute());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
back = {{
|
||||
hidden: false,
|
||||
onClick: consentAndUnmute,
|
||||
translationKey: 'dialog.UnderstandAndUnmute'
|
||||
}}
|
||||
cancel = {{ hidden: true }}
|
||||
disableBackdropClose = { true }
|
||||
disableEscape = { true }
|
||||
hideCloseButton = { true }
|
||||
ok = {{ translationKey: 'dialog.Understand' }}
|
||||
onSubmit = { consent }
|
||||
titleKey = 'dialog.recordingInProgressTitle'>
|
||||
{ t('dialog.recordingInProgressDescription') }
|
||||
{ consentLearnMoreLink && (
|
||||
<>
|
||||
{' '}
|
||||
<a
|
||||
href = { consentLearnMoreLink }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
{ t('dialog.learnMore') }
|
||||
</a>
|
||||
{'.'}
|
||||
</>
|
||||
) }
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { hideDialog } from '../../../../base/dialog/actions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import { toggleScreenshotCaptureSummary } from '../../../../screenshot-capture/actions';
|
||||
import { isScreenshotCaptureEnabled } from '../../../../screenshot-capture/functions';
|
||||
import AbstractStartRecordingDialog, {
|
||||
mapStateToProps as abstractMapStateToProps
|
||||
} from '../AbstractStartRecordingDialog';
|
||||
|
||||
import StartRecordingDialogContent from './StartRecordingDialogContent';
|
||||
|
||||
|
||||
/**
|
||||
* React Component for the recording & transcription dialog. Each section
|
||||
* (audio & video recording, transcription) has its own start/stop button
|
||||
* which applies the action immediately and closes the dialog.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class RecordingTranscriptionDialog extends AbstractStartRecordingDialog {
|
||||
|
||||
/**
|
||||
* Dismisses the dialog.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override _dismiss() {
|
||||
this.props.dispatch(hideDialog());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
isTokenValid,
|
||||
isValidating,
|
||||
localRecordingOnlySelf,
|
||||
selectedLanguage,
|
||||
selectedRecordingService,
|
||||
sharingEnabled,
|
||||
spaceLeft,
|
||||
userName
|
||||
} = this.state;
|
||||
const {
|
||||
_canTranscribe,
|
||||
_fileRecordingsServiceEnabled,
|
||||
_fileRecordingsServiceSharingEnabled,
|
||||
_recordingRunning,
|
||||
_transcriptionRunning,
|
||||
recordAudioAndVideo
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ hidden: true }}
|
||||
disableEnter = { true }
|
||||
ok = {{ hidden: true }}
|
||||
titleKey = { _canTranscribe ? 'dialog.recordAndTranscribe' : 'toolbar.record' }>
|
||||
<StartRecordingDialogContent
|
||||
fileRecordingsServiceEnabled = { _fileRecordingsServiceEnabled }
|
||||
fileRecordingsServiceSharingEnabled = { _fileRecordingsServiceSharingEnabled }
|
||||
integrationsEnabled = { this._areIntegrationsEnabled() }
|
||||
isTokenValid = { isTokenValid }
|
||||
isValidating = { isValidating }
|
||||
localRecordingOnlySelf = { localRecordingOnlySelf }
|
||||
onLocalRecordingSelfChange = { this._onLocalRecordingSelfChange }
|
||||
onRecordingServiceChange = { this._onSelectedRecordingServiceChanged }
|
||||
onSharingSettingChanged = { this._onSharingSettingChanged }
|
||||
onStartBoth = { this._onStartBothPress }
|
||||
onStartRecording = { this._onStartRecordingPress }
|
||||
onStartTranscription = { this._onStartTranscriptionPress }
|
||||
onStopBoth = { this._onStopBothPress }
|
||||
onStopRecording = { this._onStopRecordingPress }
|
||||
onStopTranscription = { this._onStopTranscriptionPress }
|
||||
onSubtitlesLanguageChange = { this._onSubtitlesLanguageChanged }
|
||||
recordAudioAndVideo = { recordAudioAndVideo }
|
||||
recordingRunning = { Boolean(_recordingRunning) }
|
||||
selectedLanguage = { selectedLanguage }
|
||||
selectedRecordingService = { selectedRecordingService }
|
||||
sharingSetting = { sharingEnabled }
|
||||
spaceLeft = { spaceLeft }
|
||||
startRecordingDisabled = { this._isStartRecordingDisabled() }
|
||||
transcriptionRunning = { Boolean(_transcriptionRunning) }
|
||||
userName = { userName } />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screenshot capture feature.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _toggleScreenshotCapture() {
|
||||
const { dispatch, _screenshotCaptureEnabled } = this.props;
|
||||
|
||||
if (_screenshotCaptureEnabled) {
|
||||
dispatch(toggleScreenshotCaptureSummary(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - Redux state.
|
||||
* @param {any} ownProps - Component's own props.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
return {
|
||||
...abstractMapStateToProps(state, ownProps),
|
||||
_screenshotCaptureEnabled: isScreenshotCaptureEnabled(state, true, false)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(RecordingTranscriptionDialog));
|
||||
@@ -0,0 +1,548 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconArrowDown } from '../../../../base/icons/svg';
|
||||
import Container from '../../../../base/react/components/web/Container';
|
||||
import Image from '../../../../base/react/components/web/Image';
|
||||
import LoadingIndicator from '../../../../base/react/components/web/LoadingIndicator';
|
||||
import Text from '../../../../base/react/components/web/Text';
|
||||
import Button from '../../../../base/ui/components/web/Button';
|
||||
import Switch from '../../../../base/ui/components/web/Switch';
|
||||
import { BUTTON_TYPES } from '../../../../base/ui/constants.web';
|
||||
import { RECORDING_TYPES } from '../../../constants';
|
||||
import { getRecordingDurationEstimation } from '../../../functions';
|
||||
import AbstractStartRecordingDialogContent, { mapStateToProps } from '../AbstractStartRecordingDialogContent';
|
||||
import {
|
||||
ICON_INFO,
|
||||
ICON_USERS,
|
||||
startRecordingDialogStyles
|
||||
} from '../styles.web';
|
||||
|
||||
import DropdownSelect from './DropdownSelect';
|
||||
|
||||
const EMPTY_FUNCTION = () => {
|
||||
// empty
|
||||
};
|
||||
|
||||
/**
|
||||
* The recording & transcription dialog content for the web application.
|
||||
*/
|
||||
class StartRecordingDialogContent extends AbstractStartRecordingDialogContent {
|
||||
override render() {
|
||||
const classes = this.props.classes ?? {};
|
||||
|
||||
return (
|
||||
<Container className = { classes.container }>
|
||||
{ this._renderRecordingSection() }
|
||||
{ this._renderTranscriptionSection() }
|
||||
{ this._renderFooter() }
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the accordion wrapping a section's options: a header with the
|
||||
* options title, a one line summary of the current choices and a chevron,
|
||||
* followed by the options themselves when expanded.
|
||||
*
|
||||
* @param {Object} options - Rendering options.
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderAccordion({ body, expanded, id, onToggle, summary, titleKey }: {
|
||||
body: React.ReactNode;
|
||||
expanded: boolean;
|
||||
id: string;
|
||||
onToggle: () => void;
|
||||
summary: string;
|
||||
titleKey: string;
|
||||
}) {
|
||||
const { t } = this.props;
|
||||
const classes = this.props.classes ?? {};
|
||||
|
||||
return (
|
||||
<Container className = { classes.accordion }>
|
||||
<button
|
||||
aria-controls = { `${id}-content` }
|
||||
aria-expanded = { expanded }
|
||||
className = { classes.accordionHeader }
|
||||
id = { id }
|
||||
onClick = { onToggle }
|
||||
type = 'button'>
|
||||
<Container className = { classes.accordionText }>
|
||||
<span className = { classes.accordionTitle }>
|
||||
{ t(titleKey) }
|
||||
</span>
|
||||
{ !expanded && (
|
||||
<span className = { classes.accordionSummary }>
|
||||
{ summary }
|
||||
</span>
|
||||
) }
|
||||
</Container>
|
||||
<Icon
|
||||
className = { `${classes.accordionChevron} ${expanded ? classes.accordionChevronOpen : ''}` }
|
||||
size = { 18 }
|
||||
src = { IconArrowDown } />
|
||||
</button>
|
||||
{ expanded && (
|
||||
<div
|
||||
className = { classes.accordionBody }
|
||||
id = { `${id}-content` }>
|
||||
{ body }
|
||||
</div>
|
||||
) }
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the audio & video recording section: the action row with the
|
||||
* start/stop button and the options accordion holding the storage service
|
||||
* combobox and the selected service's options.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderRecordingSection() {
|
||||
const recordingServiceOptions = this._getRecordingServiceOptions();
|
||||
|
||||
if (!this._shouldRenderRecordingSection(recordingServiceOptions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
isValidating,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
recordingRunning,
|
||||
selectedRecordingService,
|
||||
startRecordingDisabled,
|
||||
t
|
||||
} = this.props;
|
||||
const { showRecordingOptions } = this.state;
|
||||
const classes = this.props.classes ?? {};
|
||||
const serviceOptions = recordingServiceOptions.map(service => {
|
||||
return {
|
||||
label: t(this._getServiceLabelKey(service)),
|
||||
value: service
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Container className = { classes.section }>
|
||||
<Container className = { classes.header }>
|
||||
<label className = { classes.titleNoMargin }>
|
||||
{ t('recording.recordAudioAndVideo') }
|
||||
</label>
|
||||
<Container className = { classes.switch }>
|
||||
{ recordingRunning ? (
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.stopRecording') }
|
||||
labelKey = 'dialog.stop'
|
||||
onClick = { onStopRecording }
|
||||
testId = 'recordingDialog.stopRecording'
|
||||
type = { BUTTON_TYPES.DESTRUCTIVE } />
|
||||
) : (
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.startRecording') }
|
||||
disabled = { startRecordingDisabled || isValidating }
|
||||
labelKey = 'dialog.start'
|
||||
onClick = { onStartRecording }
|
||||
testId = 'recordingDialog.startRecording'
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
) }
|
||||
</Container>
|
||||
</Container>
|
||||
{ this._renderAccordion({
|
||||
body: (
|
||||
<>
|
||||
<DropdownSelect
|
||||
disabled = { recordingRunning || isValidating }
|
||||
id = 'recording-service-select'
|
||||
label = { t('recording.storageLocation') }
|
||||
onChange = { this._onRecordingServiceChange }
|
||||
options = { serviceOptions }
|
||||
value = { selectedRecordingService ?? '' } />
|
||||
{ selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
|
||||
&& this._renderJitsiServiceOptions() }
|
||||
{ selectedRecordingService === RECORDING_TYPES.DROPBOX
|
||||
&& this._renderDropboxOptions() }
|
||||
{ selectedRecordingService === RECORDING_TYPES.LOCAL
|
||||
&& this._renderLocalRecordingOptions() }
|
||||
{ this._renderFollowMeRecorder() }
|
||||
</>
|
||||
),
|
||||
expanded: showRecordingOptions,
|
||||
id: 'recording-options',
|
||||
onToggle: this._onToggleRecordingOptions,
|
||||
|
||||
// Only rendered by _renderAccordion while collapsed — skip computing it
|
||||
// (and its nested t() calls) while the section is expanded.
|
||||
summary: showRecordingOptions ? '' : this._getRecordingSummary(),
|
||||
titleKey: 'recording.recordingOptions'
|
||||
}) }
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the options of the Jitsi recording service: the recording link
|
||||
* sharing switch and the vpaas storage warning.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderJitsiServiceOptions() {
|
||||
const {
|
||||
isValidating,
|
||||
onSharingSettingChanged,
|
||||
recordingRunning,
|
||||
sharingSetting,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = this.props.classes ?? {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ this._shouldRenderFileSharingContent() && (
|
||||
<Container
|
||||
className = { `${classes.header} ${classes.headerSpaceTop}` }
|
||||
key = 'fileSharingSetting'>
|
||||
<Container className = { `${classes.iconContainer} ${classes.fileSharingIconContainer}` }>
|
||||
<Image
|
||||
alt = ''
|
||||
className = { classes.fileSharingIcon }
|
||||
src = { ICON_USERS } />
|
||||
</Container>
|
||||
<label
|
||||
className = { classes.title }
|
||||
htmlFor = 'recording-switch-share'>
|
||||
{ t('recording.fileSharingdescription') }
|
||||
</label>
|
||||
<Switch
|
||||
checked = { sharingSetting }
|
||||
className = { classes.switch }
|
||||
disabled = { isValidating || recordingRunning }
|
||||
id = 'recording-switch-share'
|
||||
onChange = { onSharingSettingChanged } />
|
||||
</Container>
|
||||
) }
|
||||
{ this._renderUploadToTheCloudInfo() }
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the info in case recording is uploaded to the cloud.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderUploadToTheCloudInfo() {
|
||||
const {
|
||||
_hideStorageWarning,
|
||||
isVpaas,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (!isVpaas || _hideStorageWarning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classes = this.props.classes ?? {};
|
||||
|
||||
return (
|
||||
<Container
|
||||
className = { classes.info }
|
||||
key = 'cloudUploadInfo'>
|
||||
<Image
|
||||
alt = ''
|
||||
className = { classes.infoIcon }
|
||||
src = { ICON_INFO } />
|
||||
<Text className = { classes.infoTitle }>
|
||||
{ t('recording.serviceDescriptionCloudInfo') }
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Dropbox account area: sign in button, or account info plus
|
||||
* sign out button once authorized.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderDropboxOptions() {
|
||||
const {
|
||||
isTokenValid,
|
||||
isValidating,
|
||||
spaceLeft,
|
||||
t,
|
||||
userName
|
||||
} = this.props;
|
||||
const classes = this.props.classes ?? {};
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
<Container className = { classes.authorizationPanel }>
|
||||
<LoadingIndicator size = 'small' />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isTokenValid) {
|
||||
return (
|
||||
<Container className = { `${classes.header} ${classes.headerSpaceTop}` }>
|
||||
<span className = { classes.optionLabel }>
|
||||
{ t('recording.dropboxSignInPrompt') }
|
||||
</span>
|
||||
<Container className = { classes.switch }>
|
||||
<Button
|
||||
accessibilityLabel = { t('recording.signIn') }
|
||||
labelKey = 'recording.signIn'
|
||||
onClick = { this._onSignIn }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const duration = getRecordingDurationEstimation(spaceLeft);
|
||||
|
||||
return (
|
||||
<Container className = { `${classes.header} ${classes.headerSpaceTop}` }>
|
||||
<Container className = { classes.loggedInPanel }>
|
||||
<Container>
|
||||
<Text>
|
||||
{ t('recording.loggedIn', { userName }) }
|
||||
</Text>
|
||||
</Container>
|
||||
<Container>
|
||||
<Text>
|
||||
{
|
||||
t('recording.availableSpace', {
|
||||
spaceLeft,
|
||||
duration
|
||||
})
|
||||
}
|
||||
</Text>
|
||||
</Container>
|
||||
</Container>
|
||||
<Container className = { classes.switch }>
|
||||
<Button
|
||||
accessibilityLabel = { t('recording.signOut') }
|
||||
labelKey = 'recording.signOut'
|
||||
onClick = { this._onSignOut }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the options for local recordings.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderLocalRecordingOptions() {
|
||||
const {
|
||||
_localRecordingNoNotification,
|
||||
_localRecordingSelfEnabled,
|
||||
isValidating,
|
||||
localRecordingOnlySelf,
|
||||
onLocalRecordingSelfChange,
|
||||
recordingRunning,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = this.props.classes ?? {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ _localRecordingSelfEnabled && (
|
||||
<Container className = { `${classes.header} ${classes.headerSpaceTop}` }>
|
||||
<Container className = { `${classes.iconContainer} ${classes.fileSharingIconContainer}` }>
|
||||
<Image
|
||||
alt = ''
|
||||
className = { classes.fileSharingIcon }
|
||||
src = { ICON_USERS } />
|
||||
</Container>
|
||||
<label
|
||||
className = { classes.title }
|
||||
htmlFor = 'recording-switch-myself'>
|
||||
{ t('recording.onlyRecordSelf') }
|
||||
</label>
|
||||
<Switch
|
||||
checked = { Boolean(localRecordingOnlySelf) }
|
||||
className = { classes.switch }
|
||||
disabled = { isValidating || recordingRunning }
|
||||
id = 'recording-switch-myself'
|
||||
onChange = { onLocalRecordingSelfChange ?? EMPTY_FUNCTION } />
|
||||
</Container>
|
||||
) }
|
||||
<Text className = { `${classes.localRecordingWarning} ${classes.localRecordingWarningText}` }>
|
||||
{ t('recording.localRecordingWarning') }
|
||||
</Text>
|
||||
{ _localRecordingNoNotification && !localRecordingOnlySelf
|
||||
&& <Text className = { `${classes.localRecordingWarning} ${classes.localRecordingWarningNotification}` }>
|
||||
{ t('recording.localRecordingNoNotificationWarning') }
|
||||
</Text>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the recorder follow me switch for cloud based recordings.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderFollowMeRecorder() {
|
||||
if (!this._shouldRenderFollowMeRecorder()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { t } = this.props;
|
||||
const classes = this.props.classes ?? {};
|
||||
|
||||
return (
|
||||
<Container className = { `${classes.header} ${classes.headerSpaceTop}` }>
|
||||
<label
|
||||
className = { classes.optionLabel }
|
||||
htmlFor = 'recording-switch-follow-me'>
|
||||
{ t('settings.followMeRecorder') }
|
||||
</label>
|
||||
<Switch
|
||||
checked = { this._isFollowMeRecorderChecked() }
|
||||
className = { classes.switch }
|
||||
disabled = { this._isFollowMeRecorderDisabled() }
|
||||
id = 'recording-switch-follow-me'
|
||||
onChange = { this._onFollowMeRecorderChange } />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the transcription section: header with the start/stop button
|
||||
* and a collapsible area holding the language combobox.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderTranscriptionSection() {
|
||||
if (!this._shouldRenderTranscriptionSection()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
_availableLanguages,
|
||||
onStartTranscription,
|
||||
onStopTranscription,
|
||||
selectedLanguage,
|
||||
t,
|
||||
transcriptionRunning
|
||||
} = this.props;
|
||||
const { showTranscriptionOptions } = this.state;
|
||||
const classes = this.props.classes ?? {};
|
||||
const languageOptions = _availableLanguages.map((lang: string) => {
|
||||
return {
|
||||
label: t(lang),
|
||||
value: lang
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Container className = { classes.section }>
|
||||
<Container className = { classes.header }>
|
||||
<label className = { classes.titleNoMargin }>
|
||||
{ t('recording.recordTranscription') }
|
||||
</label>
|
||||
<Container className = { classes.switch }>
|
||||
{ transcriptionRunning ? (
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.stopTranscription') }
|
||||
labelKey = 'dialog.stop'
|
||||
onClick = { onStopTranscription }
|
||||
testId = 'recordingDialog.stopTranscription'
|
||||
type = { BUTTON_TYPES.DESTRUCTIVE } />
|
||||
) : (
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.startTranscription') }
|
||||
labelKey = 'dialog.start'
|
||||
onClick = { onStartTranscription }
|
||||
testId = 'recordingDialog.startTranscription'
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
) }
|
||||
</Container>
|
||||
</Container>
|
||||
{ this._renderAccordion({
|
||||
body: (
|
||||
<DropdownSelect
|
||||
disabled = { transcriptionRunning }
|
||||
id = 'transcription-language-select'
|
||||
label = { t('recording.transcriptionLanguage') }
|
||||
onChange = { this.props.onSubtitlesLanguageChange }
|
||||
options = { languageOptions }
|
||||
value = { selectedLanguage ?? '' } />
|
||||
),
|
||||
expanded: showTranscriptionOptions,
|
||||
id: 'transcription-options',
|
||||
onToggle: this._onToggleTranscriptionOptions,
|
||||
|
||||
// Only rendered by _renderAccordion while collapsed — skip computing it while
|
||||
// the section is expanded.
|
||||
summary: showTranscriptionOptions ? '' : this._getTranscriptionSummary(),
|
||||
titleKey: 'recording.transcriptionOptions'
|
||||
}) }
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the footer buttons acting on both services at once. When only
|
||||
* one service is running both buttons are shown: stop both stops the
|
||||
* running one, start both starts the missing one.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderFooter() {
|
||||
if (!this._shouldRenderFooter()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
isValidating,
|
||||
onStartBoth,
|
||||
onStopBoth,
|
||||
recordingRunning,
|
||||
startRecordingDisabled,
|
||||
t,
|
||||
transcriptionRunning
|
||||
} = this.props;
|
||||
const showStopBoth = recordingRunning || transcriptionRunning;
|
||||
const showStartBoth = !recordingRunning || !transcriptionRunning;
|
||||
const startBothDisabled = !recordingRunning && (startRecordingDisabled || isValidating);
|
||||
const classes = this.props.classes ?? {};
|
||||
|
||||
return (
|
||||
<Container className = { classes.footer }>
|
||||
{ showStopBoth && (
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.stopBoth') }
|
||||
labelKey = 'dialog.stopBoth'
|
||||
onClick = { onStopBoth }
|
||||
testId = 'recordingDialog.stopBoth'
|
||||
type = { BUTTON_TYPES.DESTRUCTIVE } />
|
||||
) }
|
||||
{ showStartBoth && (
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.startBoth') }
|
||||
disabled = { startBothDisabled }
|
||||
labelKey = 'dialog.startBoth'
|
||||
onClick = { onStartBoth }
|
||||
testId = 'recordingDialog.startBoth'
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
) }
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(connect(mapStateToProps)(StartRecordingDialogContent)), startRecordingDialogStyles);
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import { toggleScreenshotCaptureSummary } from '../../../../screenshot-capture/actions';
|
||||
import AbstractStopRecordingDialog, {
|
||||
IProps,
|
||||
_mapStateToProps
|
||||
} from '../AbstractStopRecordingDialog';
|
||||
|
||||
/**
|
||||
* React Component for getting confirmation to stop a file recording session in
|
||||
* progress.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StopRecordingDialog extends AbstractStopRecordingDialog<IProps> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { localRecordingVideoStop, stopMode, t } = this.props;
|
||||
|
||||
const titleKey = stopMode === 'transcription' ? 'dialog.stopTranscription' : 'dialog.recording';
|
||||
const bodyKey = stopMode === 'transcription'
|
||||
? 'dialog.stopTranscriptionWarning'
|
||||
: (localRecordingVideoStop ? 'recording.localRecordingVideoStop' : 'dialog.stopRecordingWarning');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ok = {{ translationKey: 'dialog.confirm' }}
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = { titleKey }>
|
||||
{ t(bodyKey) }
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screenshot capture.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _toggleScreenshotCapture() {
|
||||
this.props.dispatch(toggleScreenshotCaptureSummary(false));
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StopRecordingDialog));
|
||||
@@ -0,0 +1,103 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
|
||||
import { getSessionStatusToShow } from '../../functions';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* Whether this meeting is being transcribed.
|
||||
*/
|
||||
_isTranscribing: boolean;
|
||||
|
||||
/**
|
||||
* The status of the highermost priority session.
|
||||
*/
|
||||
_status?: string;
|
||||
|
||||
/**
|
||||
* The recording mode this indicator should display.
|
||||
*/
|
||||
mode: string;
|
||||
|
||||
/**
|
||||
* Function to be used to translate i18n labels.
|
||||
*/
|
||||
t: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* A react {@code Component} that implements an expanded label as tooltip-like
|
||||
* component to explain the meaning of the {@code RecordingLabel}.
|
||||
*/
|
||||
class RecordingExpandedLabel extends ExpandedLabel<IProps> {
|
||||
|
||||
/**
|
||||
* Returns the label specific text of this {@code ExpandedLabel}.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_getLabel() {
|
||||
const { _status, mode, t } = this.props;
|
||||
let postfix = 'expandedOn', prefix = 'recording'; // Default values.
|
||||
|
||||
switch (mode) {
|
||||
case JitsiRecordingConstants.mode.STREAM:
|
||||
prefix = 'liveStreaming';
|
||||
break;
|
||||
case JitsiRecordingConstants.mode.FILE:
|
||||
prefix = 'recording';
|
||||
break;
|
||||
}
|
||||
|
||||
switch (_status) {
|
||||
case JitsiRecordingConstants.status.OFF:
|
||||
postfix = 'expandedOff';
|
||||
break;
|
||||
case JitsiRecordingConstants.status.PENDING:
|
||||
postfix = 'expandedPending';
|
||||
break;
|
||||
case JitsiRecordingConstants.status.ON:
|
||||
postfix = 'expandedOn';
|
||||
break;
|
||||
}
|
||||
|
||||
let content = t(`${prefix}.${postfix}`);
|
||||
|
||||
if (this.props._isTranscribing) {
|
||||
if (_status === JitsiRecordingConstants.status.ON) {
|
||||
content += ` ${t('transcribing.labelTooltipExtra')}`;
|
||||
} else {
|
||||
content = t('transcribing.labelTooltip');
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated
|
||||
* {@code RecordingExpandedLabel}'s props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {IProps} ownProps - The component's own props.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _status: ?string
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
const { mode } = ownProps;
|
||||
|
||||
return {
|
||||
_isTranscribing: isRecorderTranscriptionsRunning(state),
|
||||
_status: getSessionStatusToShow(state, mode)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(RecordingExpandedLabel));
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconRecord, IconSites } from '../../../base/icons/svg';
|
||||
import Label from '../../../base/label/components/native/Label';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { StyleType } from '../../../base/styles/functions.any';
|
||||
import AbstractRecordingLabel, {
|
||||
_mapStateToProps
|
||||
} from '../AbstractRecordingLabel';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} which displays the current state of
|
||||
* conference recording.
|
||||
*
|
||||
* @augments {Component}
|
||||
*/
|
||||
class RecordingLabel extends AbstractRecordingLabel {
|
||||
|
||||
/**
|
||||
* Renders the platform specific label component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
_renderLabel() {
|
||||
let status: 'on' | 'in_progress' | 'off' = 'on';
|
||||
const isRecording = this.props.mode === JitsiRecordingConstants.mode.FILE;
|
||||
const icon = isRecording ? IconRecord : IconSites;
|
||||
|
||||
switch (this.props._status) {
|
||||
case JitsiRecordingConstants.status.PENDING:
|
||||
status = 'in_progress';
|
||||
break;
|
||||
case JitsiRecordingConstants.status.OFF:
|
||||
status = 'off';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
icon = { icon }
|
||||
status = { status }
|
||||
style = { styles.indicatorStyle as StyleType } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(RecordingLabel));
|
||||
@@ -0,0 +1,55 @@
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
|
||||
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
|
||||
|
||||
interface IProps extends AbstractProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* Whether this meeting is being transcribed.
|
||||
*/
|
||||
_isTranscribing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A react {@code Component} that implements an expanded label as tooltip-like
|
||||
* component to explain the meaning of the {@code TranscribingLabel}.
|
||||
*/
|
||||
class TranscribingExpandedLabel extends ExpandedLabel<IProps> {
|
||||
|
||||
/**
|
||||
* Returns the label specific text of this {@code ExpandedLabel}.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_getLabel() {
|
||||
const { _isTranscribing, t } = this.props;
|
||||
|
||||
if (_isTranscribing) {
|
||||
return t('transcribing.labelTooltip');
|
||||
}
|
||||
|
||||
return t('transcribing.expandedOff');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated
|
||||
* {@code TranscribingExpandedLabel}'s props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _isTranscribing: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_isTranscribing: isRecorderTranscriptionsRunning(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(TranscribingExpandedLabel));
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconTranscription } from '../../../base/icons/svg';
|
||||
import Label from '../../../base/label/components/native/Label';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { StyleType } from '../../../base/styles/functions.any';
|
||||
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
|
||||
import AbstractRecordingLabel, {
|
||||
IProps as AbstractProps
|
||||
} from '../AbstractRecordingLabel';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} which displays the current state of
|
||||
* transcription.
|
||||
*
|
||||
* @augments {Component}
|
||||
*/
|
||||
class TranscribingLabel extends AbstractRecordingLabel<AbstractProps> {
|
||||
|
||||
/**
|
||||
* Renders the platform specific label component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
_renderLabel() {
|
||||
const { _isTranscribing } = this.props;
|
||||
|
||||
if (!_isTranscribing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
icon = { IconTranscription }
|
||||
status = { 'on' }
|
||||
style = { styles.transcribingIndicatorStyle as StyleType } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: any) {
|
||||
const _isTranscribing = isRecorderTranscriptionsRunning(state);
|
||||
|
||||
return {
|
||||
_isVisible: _isTranscribing,
|
||||
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
|
||||
_isTranscribing,
|
||||
mode: 'transcribing', // Custom mode for transcription
|
||||
_status: _isTranscribing ? JitsiRecordingConstants.status.ON : JitsiRecordingConstants.status.OFF
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(TranscribingLabel));
|
||||
28
react/features/recording/components/native/styles.ts
Normal file
28
react/features/recording/components/native/styles.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createStyleSheet } from '../../../base/styles/functions.native';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme';
|
||||
|
||||
/**
|
||||
* The styles of the React {@code Components} of the feature recording.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
|
||||
/**
|
||||
* Style for the recording indicator.
|
||||
*/
|
||||
indicatorStyle: {
|
||||
marginRight: 4,
|
||||
marginLeft: 0,
|
||||
marginBottom: 0,
|
||||
backgroundColor: BaseTheme.palette.iconError
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for the transcription indicator.
|
||||
*/
|
||||
transcribingIndicatorStyle: {
|
||||
marginRight: 4,
|
||||
marginLeft: 0,
|
||||
marginBottom: 0,
|
||||
backgroundColor: BaseTheme.palette.transcriptionIndicator
|
||||
}
|
||||
});
|
||||
131
react/features/recording/components/web/RecordingLabel.tsx
Normal file
131
react/features/recording/components/web/RecordingLabel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconRecord, IconSites } from '../../../base/icons/svg';
|
||||
import Label from '../../../base/label/components/web/Label';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import Tooltip from '../../../base/tooltip/components/Tooltip';
|
||||
import { hasRecordingOrTranscriptionFeature } from '../../functions';
|
||||
import AbstractRecordingLabel, {
|
||||
IProps as AbstractProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractRecordingLabel';
|
||||
import RecordingTranscriptionDialog from '../Recording/web/RecordingTranscriptionDialog';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* Whether the local participant can control recording/transcription (has either feature enabled).
|
||||
*/
|
||||
_canControlRecording: boolean;
|
||||
|
||||
/**
|
||||
* An object containing the CSS classes.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the styles for the component.
|
||||
*
|
||||
* @param {Object} theme - The current UI theme.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
record: {
|
||||
background: theme.palette.actionDanger
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} which displays the current state of
|
||||
* conference recording.
|
||||
*
|
||||
* @augments {Component}
|
||||
*/
|
||||
class RecordingLabel extends AbstractRecordingLabel<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code RecordingLabel} instance.
|
||||
*
|
||||
* @param {IProps} props - The props of the component.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking on the label.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
if (this.props._canControlRecording) {
|
||||
this.props.dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the platform specific label component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override _renderLabel() {
|
||||
const { _status, mode, t } = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
const isRecording = mode === JitsiRecordingConstants.mode.FILE;
|
||||
const icon = isRecording ? IconRecord : IconSites;
|
||||
let content;
|
||||
|
||||
if (_status === JitsiRecordingConstants.status.ON) {
|
||||
content = t(isRecording ? 'videoStatus.recording' : 'videoStatus.streaming');
|
||||
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
|
||||
return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content = { content }
|
||||
position = { 'bottom' }>
|
||||
<Label
|
||||
className = { classes.record }
|
||||
icon = { icon }
|
||||
onClick = { this._onClick } />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - The component's own props.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
return {
|
||||
..._abstractMapStateToProps(state, ownProps),
|
||||
_canControlRecording: hasRecordingOrTranscriptionFeature(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default withStyles(translate(connect(_mapStateToProps)(RecordingLabel)), styles);
|
||||
129
react/features/recording/components/web/TranscribingLabel.tsx
Normal file
129
react/features/recording/components/web/TranscribingLabel.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconTranscription } from '../../../base/icons/svg';
|
||||
import Label from '../../../base/label/components/web/Label';
|
||||
import Tooltip from '../../../base/tooltip/components/Tooltip';
|
||||
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
|
||||
import { hasRecordingOrTranscriptionFeature } from '../../functions';
|
||||
import AbstractRecordingLabel, {
|
||||
IProps as AbstractProps
|
||||
} from '../AbstractRecordingLabel';
|
||||
import RecordingTranscriptionDialog from '../Recording/web/RecordingTranscriptionDialog';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* Whether the local participant can control recording/transcription (has either feature enabled).
|
||||
*/
|
||||
_canControlRecording: boolean;
|
||||
|
||||
/**
|
||||
* An object containing the CSS classes.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the styles for the component.
|
||||
*
|
||||
* @param {Object} theme - The current UI theme.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
transcribing: {
|
||||
background: theme.palette.transcriptionIndicator
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} which displays the current state of
|
||||
* transcription.
|
||||
*
|
||||
* @augments {Component}
|
||||
*/
|
||||
class TranscribingLabel extends AbstractRecordingLabel<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code TranscribingLabel} instance.
|
||||
*
|
||||
* @param {IProps} props - The props of the component.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking on the label.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
if (this.props._canControlRecording) {
|
||||
this.props.dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the platform specific label component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override _renderLabel() {
|
||||
const { _isTranscribing, t } = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
if (!_isTranscribing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = t('transcribing.labelTooltip');
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content = { content }
|
||||
position = { 'bottom' }>
|
||||
<Label
|
||||
className = { classes.transcribing }
|
||||
icon = { IconTranscription }
|
||||
onClick = { this._onClick } />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const _isTranscribing = isRecorderTranscriptionsRunning(state);
|
||||
|
||||
return {
|
||||
_isVisible: _isTranscribing,
|
||||
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
|
||||
_canControlRecording: hasRecordingOrTranscriptionFeature(state),
|
||||
_isTranscribing,
|
||||
mode: 'transcribing' // Custom mode for transcription
|
||||
};
|
||||
}
|
||||
|
||||
export default withStyles(translate(connect(_mapStateToProps)(TranscribingLabel)), styles);
|
||||
92
react/features/recording/constants.ts
Normal file
92
react/features/recording/constants.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
|
||||
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when a live streaming session is stopped.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const LIVE_STREAMING_OFF_SOUND_ID = 'LIVE_STREAMING_OFF_SOUND';
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when a live streaming session is started.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const LIVE_STREAMING_ON_SOUND_ID = 'LIVE_STREAMING_ON_SOUND';
|
||||
|
||||
/**
|
||||
* The identifier of the prompt to start recording notification.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const PROMPT_RECORDING_NOTIFICATION_ID = 'PROMPT_RECORDING_NOTIFICATION_ID';
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when a recording session is stopped.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const RECORDING_OFF_SOUND_ID = 'RECORDING_OFF_SOUND';
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when a recording session is started.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const RECORDING_ON_SOUND_ID = 'RECORDING_ON_SOUND';
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when transcription is stopped.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const TRANSCRIPTION_OFF_SOUND_ID = 'TRANSCRIPTION_OFF_SOUND';
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when transcription is started.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const TRANSCRIPTION_ON_SOUND_ID = 'TRANSCRIPTION_ON_SOUND';
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when recording and transcription are stopped.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID = 'RECORDING_AND_TRANSCRIPTION_OFF_SOUND';
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when recording and transcription are started.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID = 'RECORDING_AND_TRANSCRIPTION_ON_SOUND';
|
||||
|
||||
/**
|
||||
* Expected supported recording types.
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const RECORDING_TYPES = {
|
||||
JITSI_REC_SERVICE: 'recording-service',
|
||||
DROPBOX: 'dropbox',
|
||||
LOCAL: 'local'
|
||||
};
|
||||
|
||||
/**
|
||||
* An array defining the priorities of the recording (or live streaming)
|
||||
* statuses, where the index of the array is the priority itself.
|
||||
*
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
export const RECORDING_STATUS_PRIORITIES = [
|
||||
JitsiRecordingConstants.status.OFF,
|
||||
JitsiRecordingConstants.status.PENDING,
|
||||
JitsiRecordingConstants.status.ON
|
||||
];
|
||||
|
||||
export const START_RECORDING_NOTIFICATION_ID = 'START_RECORDING_NOTIFICATION_ID';
|
||||
|
||||
export const RECORDING_METADATA_ID = 'recording';
|
||||
530
react/features/recording/functions.ts
Normal file
530
react/features/recording/functions.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import i18next from 'i18next';
|
||||
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
|
||||
import { getSoundFileSrc } from '../base/media/functions';
|
||||
import { getLocalParticipant, getRemoteParticipants } from '../base/participants/functions';
|
||||
import { registerSound, unregisterSound } from '../base/sounds/actions';
|
||||
import { isEmbedded, isEmbeddedFromSameDomain } from '../base/util/embedUtils';
|
||||
import { isSpotTV } from '../base/util/spot';
|
||||
import { isInBreakoutRoom as isInBreakoutRoomF } from '../breakout-rooms/functions';
|
||||
import { isEnabled as isDropboxEnabled } from '../dropbox/functions';
|
||||
import { extractFqnFromPath } from '../dynamic-branding/functions.any';
|
||||
import { isVpaasMeeting } from '../jaas/functions';
|
||||
import { canAddTranscriber, isRecorderTranscriptionsRunning } from '../transcribing/functions';
|
||||
import { iAmVisitor } from '../visitors/functions';
|
||||
|
||||
import LocalRecordingManager from './components/Recording/LocalRecordingManager';
|
||||
import {
|
||||
LIVE_STREAMING_OFF_SOUND_ID,
|
||||
LIVE_STREAMING_ON_SOUND_ID,
|
||||
RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID,
|
||||
RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID,
|
||||
RECORDING_OFF_SOUND_ID,
|
||||
RECORDING_ON_SOUND_ID,
|
||||
RECORDING_STATUS_PRIORITIES,
|
||||
RECORDING_TYPES,
|
||||
TRANSCRIPTION_OFF_SOUND_ID,
|
||||
TRANSCRIPTION_ON_SOUND_ID
|
||||
} from './constants';
|
||||
import logger from './logger';
|
||||
import {
|
||||
LIVE_STREAMING_OFF_SOUND_FILE,
|
||||
LIVE_STREAMING_ON_SOUND_FILE,
|
||||
RECORDING_AND_TRANSCRIPTION_OFF_SOUND_FILE,
|
||||
RECORDING_AND_TRANSCRIPTION_ON_SOUND_FILE,
|
||||
RECORDING_OFF_SOUND_FILE,
|
||||
RECORDING_ON_SOUND_FILE,
|
||||
TRANSCRIPTION_OFF_SOUND_FILE,
|
||||
TRANSCRIPTION_ON_SOUND_FILE
|
||||
} from './sounds';
|
||||
|
||||
/**
|
||||
* Searches in the passed in redux state for an active recording session of the
|
||||
* passed in mode.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @param {string} mode - Find an active recording session of the given mode.
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
export function getActiveSession(state: IReduxState, mode: string) {
|
||||
const { sessionDatas } = state['features/recording'];
|
||||
const { status: statusConstants } = JitsiRecordingConstants;
|
||||
|
||||
return sessionDatas.find(sessionData => sessionData.mode === mode
|
||||
&& (sessionData.status === statusConstants.ON
|
||||
|| sessionData.status === statusConstants.PENDING));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an estimated recording duration based on the size of the video file
|
||||
* in MB. The estimate is calculated under the assumption that 1 min of recorded
|
||||
* video needs 10MB of storage on average.
|
||||
*
|
||||
* @param {number} size - The size in MB of the recorded video.
|
||||
* @returns {number} - The estimated duration in minutes.
|
||||
*/
|
||||
export function getRecordingDurationEstimation(size?: number | null) {
|
||||
return Math.floor((size || 0) / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches in the passed in redux state for a recording session that matches
|
||||
* the passed in recording session ID.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @param {string} id - The ID of the recording session to find.
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
export function getSessionById(state: IReduxState, id: string) {
|
||||
return state['features/recording'].sessionDatas.find(
|
||||
sessionData => sessionData.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the recording link from the server.
|
||||
*
|
||||
* @param {string} url - The base url.
|
||||
* @param {string} recordingSessionId - The ID of the recording session to find.
|
||||
* @param {string} region - The meeting region.
|
||||
* @param {string} tenant - The meeting tenant.
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function getRecordingLink(url: string, recordingSessionId: string, region: string, tenant: string) {
|
||||
const fullUrl = `${url}?recordingSessionId=${recordingSessionId}®ion=${region}&tenant=${tenant}`;
|
||||
const res = await fetch(fullUrl, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
return res.ok ? json : Promise.reject(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector used for determining if recording is saved on dropbox.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function isSavingRecordingOnDropbox(state: IReduxState) {
|
||||
return isDropboxEnabled(state)
|
||||
&& state['features/recording'].selectedRecordingService === RECORDING_TYPES.DROPBOX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector used for determining disable state for the meeting highlight button.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function isHighlightMeetingMomentDisabled(state: IReduxState) {
|
||||
return state['features/recording'].disableHighlightMeetingMoment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the recording session status that is to be shown in a label. E.g. If
|
||||
* there is a session with the status OFF and one with PENDING, then the PENDING
|
||||
* one will be shown, because that is likely more important for the user to see.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @param {string} mode - The recording mode to get status for.
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function getSessionStatusToShow(state: IReduxState, mode: string): string | undefined {
|
||||
const recordingSessions = state['features/recording'].sessionDatas;
|
||||
let status;
|
||||
|
||||
if (Array.isArray(recordingSessions)) {
|
||||
for (const session of recordingSessions) {
|
||||
if (session.mode === mode
|
||||
&& (!status
|
||||
|| (RECORDING_STATUS_PRIORITIES.indexOf(session.status)
|
||||
> RECORDING_STATUS_PRIORITIES.indexOf(status)))) {
|
||||
status = session.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode === JitsiRecordingConstants.mode.FILE
|
||||
&& (state['features/recording'].localRecordingRunning || isRemoteParticipantRecordingLocally(state))) {
|
||||
status = JitsiRecordingConstants.status.ON;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local recording is supported.
|
||||
*
|
||||
* @returns {boolean} - Whether local recording is supported or not.
|
||||
*/
|
||||
export function supportsLocalRecording() {
|
||||
return LocalRecordingManager.isSupported() && (!isEmbedded() || isEmbeddedFromSameDomain());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is a cloud recording running.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state to search in.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCloudRecordingRunning(state: IReduxState) {
|
||||
return Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is a live streaming running.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state to search in.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLiveStreamingRunning(state: IReduxState) {
|
||||
return Boolean(getActiveSession(state, JitsiRecordingConstants.mode.STREAM));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is a recording session running.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRecordingRunning(state: IReduxState) {
|
||||
return (
|
||||
isCloudRecordingRunning(state)
|
||||
|| Boolean(state['features/recording'].localRecordingRunning)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the participant has either the recording or transcription JWT feature enabled.
|
||||
* Used to determine if the user can interact with recording/transcription controls.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasRecordingOrTranscriptionFeature(state: IReduxState) {
|
||||
return isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false)
|
||||
|| isJwtFeatureEnabled(state, MEET_FEATURES.TRANSCRIPTION, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the participant can stop recording.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canStopRecording(state: IReduxState) {
|
||||
if (state['features/recording'].localRecordingRunning) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isCloudRecordingRunning(state) || isRecorderTranscriptionsRunning(state)) {
|
||||
return hasRecordingOrTranscriptionFeature(state);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the transcription should start automatically when recording starts.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldAutoTranscribeOnRecord(state: IReduxState) {
|
||||
const { transcription } = state['features/base/config'];
|
||||
|
||||
return (transcription?.autoTranscribeOnRecord ?? true) && canAddTranscriber(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the recording should be shared.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRecordingSharingEnabled(state: IReduxState) {
|
||||
const { recordingService } = state['features/base/config'];
|
||||
|
||||
return recordingService?.sharingEnabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the recording button props.
|
||||
*
|
||||
* @param {Object} state - The redux state to search in.
|
||||
*
|
||||
* @returns {{
|
||||
* disabled: boolean,
|
||||
* tooltip: string,
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function getRecordButtonProps(state: IReduxState) {
|
||||
let visible;
|
||||
|
||||
// a button can be disabled/enabled if enableFeaturesBasedOnToken
|
||||
// is on or if the livestreaming is running.
|
||||
let disabled = false;
|
||||
const tooltip = isRecordingRunning(state) ? 'dialog.stopRecording' : 'dialog.startRecording';
|
||||
|
||||
// If the containing component provides the visible prop, that is one
|
||||
// above all, but if not, the button should be autonomus and decide on
|
||||
// its own to be visible or not.
|
||||
const {
|
||||
recordingService,
|
||||
localRecording,
|
||||
transcription
|
||||
} = state['features/base/config'];
|
||||
const localRecordingEnabled = !localRecording?.disable && supportsLocalRecording();
|
||||
|
||||
const dropboxEnabled = isDropboxEnabled(state);
|
||||
const recordingEnabled = recordingService?.enabled || dropboxEnabled;
|
||||
const transcriptionEnabled = transcription?.enabled;
|
||||
|
||||
if (localRecordingEnabled) {
|
||||
visible = true;
|
||||
} else if (isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false)) {
|
||||
visible = recordingEnabled;
|
||||
} else if (isJwtFeatureEnabled(state, MEET_FEATURES.TRANSCRIPTION, false)) {
|
||||
visible = transcriptionEnabled;
|
||||
} else if (!isVpaasMeeting(state)) {
|
||||
// Self-hosted without JWT: fall back to server config so moderators
|
||||
// see the button when recordingService.enabled or transcription.enabled.
|
||||
visible = recordingEnabled || transcriptionEnabled;
|
||||
}
|
||||
|
||||
// disable the button if we are in a breakout room.
|
||||
if (isInBreakoutRoomF(state)) {
|
||||
disabled = true;
|
||||
visible = false;
|
||||
}
|
||||
|
||||
return {
|
||||
disabled,
|
||||
tooltip,
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resource id.
|
||||
*
|
||||
* @param {Object | string} recorder - A participant or it's resource.
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function getResourceId(recorder: string | { getId: Function; }) {
|
||||
if (recorder) {
|
||||
return typeof recorder === 'string'
|
||||
? recorder
|
||||
: recorder.getId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a meeting highlight to backend.
|
||||
*
|
||||
* @param {Object} state - Redux state.
|
||||
* @returns {boolean} - True if sent, false otherwise.
|
||||
*/
|
||||
export async function sendMeetingHighlight(state: IReduxState) {
|
||||
const { webhookProxyUrl: url } = state['features/base/config'];
|
||||
const { conference } = state['features/base/conference'];
|
||||
const { jwt } = state['features/base/jwt'];
|
||||
const { connection } = state['features/base/connection'];
|
||||
const jid = connection?.getJid();
|
||||
const localParticipant = getLocalParticipant(state);
|
||||
|
||||
const headers = {
|
||||
...jwt ? { 'Authorization': `Bearer ${jwt}` } : {},
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const reqBody = {
|
||||
meetingFqn: extractFqnFromPath(state),
|
||||
sessionId: conference?.getMeetingUniqueId(),
|
||||
submitted: Date.now(),
|
||||
participantId: localParticipant?.jwtId,
|
||||
participantName: localParticipant?.name,
|
||||
participantJid: jid
|
||||
};
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
const res = await fetch(`${url}/v2/highlights`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(reqBody)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return true;
|
||||
}
|
||||
logger.error('Status error:', res.status);
|
||||
} catch (err) {
|
||||
logger.error('Could not send request', err);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a remote participant is recording locally or not.
|
||||
*
|
||||
* @param {Object} state - Redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRemoteParticipantRecordingLocally(state: IReduxState) {
|
||||
const participants = getRemoteParticipants(state);
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
for (let value of participants.values()) {
|
||||
if (value.localRecording) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the audio files based on locale.
|
||||
*
|
||||
* @param {Dispatch<any>} dispatch - The redux dispatch function.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function unregisterRecordingAudioFiles(dispatch: IStore['dispatch']) {
|
||||
dispatch(unregisterSound(LIVE_STREAMING_OFF_SOUND_FILE));
|
||||
dispatch(unregisterSound(LIVE_STREAMING_ON_SOUND_FILE));
|
||||
dispatch(unregisterSound(RECORDING_OFF_SOUND_FILE));
|
||||
dispatch(unregisterSound(RECORDING_ON_SOUND_FILE));
|
||||
dispatch(unregisterSound(TRANSCRIPTION_OFF_SOUND_FILE));
|
||||
dispatch(unregisterSound(TRANSCRIPTION_ON_SOUND_FILE));
|
||||
dispatch(unregisterSound(RECORDING_AND_TRANSCRIPTION_OFF_SOUND_FILE));
|
||||
dispatch(unregisterSound(RECORDING_AND_TRANSCRIPTION_ON_SOUND_FILE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the audio files based on locale.
|
||||
*
|
||||
* @param {Dispatch<any>} dispatch - The redux dispatch function.
|
||||
* @param {boolean|undefined} shouldUnregister - Whether the sounds should be unregistered.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function registerRecordingAudioFiles(dispatch: IStore['dispatch'], shouldUnregister?: boolean) {
|
||||
const language = i18next.language;
|
||||
|
||||
if (shouldUnregister) {
|
||||
unregisterRecordingAudioFiles(dispatch);
|
||||
}
|
||||
|
||||
dispatch(registerSound(
|
||||
LIVE_STREAMING_OFF_SOUND_ID,
|
||||
getSoundFileSrc(LIVE_STREAMING_OFF_SOUND_FILE, language)));
|
||||
|
||||
dispatch(registerSound(
|
||||
LIVE_STREAMING_ON_SOUND_ID,
|
||||
getSoundFileSrc(LIVE_STREAMING_ON_SOUND_FILE, language)));
|
||||
|
||||
dispatch(registerSound(
|
||||
RECORDING_OFF_SOUND_ID,
|
||||
getSoundFileSrc(RECORDING_OFF_SOUND_FILE, language)));
|
||||
|
||||
dispatch(registerSound(
|
||||
RECORDING_ON_SOUND_ID,
|
||||
getSoundFileSrc(RECORDING_ON_SOUND_FILE, language)));
|
||||
|
||||
dispatch(registerSound(
|
||||
TRANSCRIPTION_OFF_SOUND_ID,
|
||||
getSoundFileSrc(TRANSCRIPTION_OFF_SOUND_FILE, language)));
|
||||
|
||||
dispatch(registerSound(
|
||||
TRANSCRIPTION_ON_SOUND_ID,
|
||||
getSoundFileSrc(TRANSCRIPTION_ON_SOUND_FILE, language)));
|
||||
|
||||
dispatch(registerSound(
|
||||
RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID,
|
||||
getSoundFileSrc(RECORDING_AND_TRANSCRIPTION_OFF_SOUND_FILE, language)));
|
||||
|
||||
dispatch(registerSound(
|
||||
RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID,
|
||||
getSoundFileSrc(RECORDING_AND_TRANSCRIPTION_ON_SOUND_FILE, language)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the live-streaming button should be visible.
|
||||
*
|
||||
* @param {boolean} liveStreamingEnabled - True if the live-streaming is enabled.
|
||||
* @param {boolean} liveStreamingAllowed - True if the live-streaming feature is enabled in JWT
|
||||
* or is a moderator if JWT is missing or features are missing in JWT.
|
||||
* @param {boolean} isInBreakoutRoom - True if in breakout room.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLiveStreamingButtonVisible({
|
||||
liveStreamingAllowed,
|
||||
liveStreamingEnabled,
|
||||
isInBreakoutRoom
|
||||
}: {
|
||||
isInBreakoutRoom: boolean;
|
||||
liveStreamingAllowed: boolean;
|
||||
liveStreamingEnabled: boolean;
|
||||
}) {
|
||||
return !isInBreakoutRoom && liveStreamingEnabled && liveStreamingAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the RecordingConsentDialog should be displayed.
|
||||
*
|
||||
* @param {any} recorderSession - The recorder session.
|
||||
* @param {IReduxState} state - The Redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldRequireRecordingConsent(recorderSession: any, state: IReduxState) {
|
||||
const { requireRecordingConsent, skipRecordingConsentInMeeting }
|
||||
= state['features/dynamic-branding'] || {};
|
||||
const { conference } = state['features/base/conference'] || {};
|
||||
const { requireConsent, skipConsentInMeeting } = state['features/base/config'].recordings || {};
|
||||
const _iAmVisitor = iAmVisitor(state);
|
||||
const { iAmRecorder, testing: { showSpotConsentDialog = false } = {} } = state['features/base/config'];
|
||||
const { consentRequested } = state['features/recording'];
|
||||
|
||||
if (iAmRecorder || _iAmVisitor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For Spot TV instances, check the showSpotConsentDialog config parameter
|
||||
// If showSpotConsentDialog is false (or undefined, defaulting to false), don't show consent dialog
|
||||
if (isSpotTV(state) && !showSpotConsentDialog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!requireConsent && !requireRecordingConsent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (consentRequested.has(recorderSession.getID())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we join a meeting that has an ongoing recording `conference` will be undefined since
|
||||
// we get the recording state through the initial presence which happens in between the
|
||||
// WILL_JOIN and JOINED events.
|
||||
if (conference && (skipConsentInMeeting || skipRecordingConsentInMeeting)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// lib-jitsi-meet may set a JitsiParticipant as the initiator of the recording session or the
|
||||
// JID resource in case it cannot find it. We need to handle both cases.
|
||||
const initiator = recorderSession.getInitiator();
|
||||
const initiatorId = initiator?.getId?.() ?? initiator;
|
||||
|
||||
if (!initiatorId || recorderSession.getStatus() === JitsiRecordingConstants.status.OFF) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return initiatorId !== getLocalParticipant(state)?.id;
|
||||
}
|
||||
61
react/features/recording/hooks.web.ts
Normal file
61
react/features/recording/hooks.web.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../app/types';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { isInBreakoutRoom } from '../breakout-rooms/functions';
|
||||
|
||||
import { getLiveStreaming } from './components/LiveStream/functions';
|
||||
import LiveStreamButton from './components/LiveStream/web/LiveStreamButton';
|
||||
import RecordButton from './components/Recording/web/RecordButton';
|
||||
import { getRecordButtonProps, isLiveStreamingButtonVisible } from './functions';
|
||||
|
||||
|
||||
const recording = {
|
||||
key: 'recording',
|
||||
Content: RecordButton,
|
||||
group: 2
|
||||
};
|
||||
|
||||
const livestreaming = {
|
||||
key: 'livestreaming',
|
||||
Content: LiveStreamButton,
|
||||
group: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that returns the recording button if it is enabled and undefined otherwise.
|
||||
*
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
export function useRecordingButton() {
|
||||
const recordingProps = useSelector(getRecordButtonProps);
|
||||
const toolbarButtons = useSelector((state: IReduxState) => state['features/toolbox'].toolbarButtons);
|
||||
|
||||
if (toolbarButtons?.includes('recording') && recordingProps.visible) {
|
||||
return recording;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that returns the livestreaming button if it is enabled and undefined otherwise.
|
||||
*
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
export function useLiveStreamingButton() {
|
||||
const toolbarButtons = useSelector((state: IReduxState) => state['features/toolbox'].toolbarButtons);
|
||||
const liveStreaming = useSelector(getLiveStreaming);
|
||||
const liveStreamingAllowed = useSelector((state: IReduxState) =>
|
||||
isJwtFeatureEnabled(state, MEET_FEATURES.LIVESTREAMING, false));
|
||||
const _isInBreakoutRoom = useSelector(isInBreakoutRoom);
|
||||
|
||||
if (toolbarButtons?.includes('recording')
|
||||
&& isLiveStreamingButtonVisible({
|
||||
liveStreamingAllowed,
|
||||
liveStreamingEnabled: liveStreaming?.enabled,
|
||||
isInBreakoutRoom: _isInBreakoutRoom
|
||||
})) {
|
||||
return livestreaming;
|
||||
}
|
||||
}
|
||||
3
react/features/recording/logger.ts
Normal file
3
react/features/recording/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('app:recording');
|
||||
726
react/features/recording/middleware.ts
Normal file
726
react/features/recording/middleware.ts
Normal file
@@ -0,0 +1,726 @@
|
||||
import { batch } from 'react-redux';
|
||||
|
||||
import { createRecordingEvent } from '../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../analytics/functions';
|
||||
import { IStore } from '../app/types';
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
|
||||
import { CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import JitsiMeetJS, {
|
||||
JitsiConferenceEvents,
|
||||
JitsiRecordingConstants
|
||||
} from '../base/lib-jitsi-meet';
|
||||
import {
|
||||
setAudioMuted,
|
||||
setAudioUnmutePermissions,
|
||||
setVideoMuted,
|
||||
setVideoUnmutePermissions
|
||||
} from '../base/media/actions';
|
||||
import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import { PARTICIPANT_UPDATED } from '../base/participants/actionTypes';
|
||||
import { updateLocalRecordingStatus } from '../base/participants/actions';
|
||||
import { PARTICIPANT_ROLE } from '../base/participants/constants';
|
||||
import {
|
||||
getLocalParticipant,
|
||||
getParticipantDisplayName,
|
||||
isLocalParticipantModerator
|
||||
} from '../base/participants/functions';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import {
|
||||
playSound,
|
||||
stopSound
|
||||
} from '../base/sounds/actions';
|
||||
import { TRACK_ADDED } from '../base/tracks/actionTypes';
|
||||
import { hideNotification, showErrorNotification, showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
|
||||
import { canAddTranscriber, isRecorderTranscriptionsRunning, isTranscribing } from '../transcribing/functions';
|
||||
|
||||
import { RECORDING_SESSION_UPDATED, START_LOCAL_RECORDING, STOP_LOCAL_RECORDING } from './actionTypes';
|
||||
import {
|
||||
clearRecordingSessions,
|
||||
hidePendingRecordingNotification,
|
||||
markConsentRequested,
|
||||
setLocalRecordingRunning,
|
||||
setStartRecordingIntent,
|
||||
setStopRecordingIntent,
|
||||
showPendingRecordingNotification,
|
||||
showRecordingError,
|
||||
showRecordingWarning,
|
||||
showStartRecordingNotification,
|
||||
showStartedRecordingNotification,
|
||||
showStoppedRecordingNotification,
|
||||
updateRecordingSessionData
|
||||
} from './actions';
|
||||
import { RecordingConsentDialog } from './components/Recording';
|
||||
import LocalRecordingManager from './components/Recording/LocalRecordingManager';
|
||||
import {
|
||||
LIVE_STREAMING_OFF_SOUND_ID,
|
||||
LIVE_STREAMING_ON_SOUND_ID,
|
||||
RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID,
|
||||
RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID,
|
||||
RECORDING_OFF_SOUND_ID,
|
||||
RECORDING_ON_SOUND_ID,
|
||||
START_RECORDING_NOTIFICATION_ID,
|
||||
TRANSCRIPTION_OFF_SOUND_ID,
|
||||
TRANSCRIPTION_ON_SOUND_ID
|
||||
} from './constants';
|
||||
import {
|
||||
getResourceId,
|
||||
getSessionById,
|
||||
registerRecordingAudioFiles,
|
||||
shouldRequireRecordingConsent,
|
||||
unregisterRecordingAudioFiles
|
||||
} from './functions';
|
||||
import logger from './logger';
|
||||
import { getNudge } from './nudge';
|
||||
import { ISessionData } from './reducer';
|
||||
|
||||
/**
|
||||
* Evaluates whether all intended services (recording and/or transcription) have
|
||||
* resolved (succeeded or failed) and plays the appropriate start sound and notification.
|
||||
*
|
||||
* Intent source: {@code startRecordingIntent} — populated on the local side by the
|
||||
* start dialog / auto-start callback, and on remote observers by the metadata listener
|
||||
* when a false→true transition of isRecordingRequested / isTranscribingEnabled is seen.
|
||||
*
|
||||
* Resolution is derived from existing Redux state — no separate tracking needed.
|
||||
* The recording notification's "started by …" name is read directly from the active
|
||||
* FILE session in {@code sessionDatas}. When recording is on but the initiator has not
|
||||
* yet been delivered by jicofo, the function waits — keeping the intent alive.
|
||||
*
|
||||
* Called from:
|
||||
* - Recording middleware when RECORDING_SESSION_UPDATED arrives with the initiator
|
||||
* (jicofo update) or with an error.
|
||||
* - Transcription subscriber when isRecorderTranscriptionsRunning becomes true.
|
||||
* - Subtitles middleware when conference.dial() fails.
|
||||
* - Transcribing middleware when TRANSCRIBER_LEFT abruptly.
|
||||
* - Metadata change listener when room metadata updates.
|
||||
*
|
||||
* @param {Function} dispatch - Redux dispatch.
|
||||
* @param {Function} getState - Redux getState.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function maybeNotifyRecordingStart(dispatch: IStore['dispatch'], getState: IStore['getState']) {
|
||||
const state = getState();
|
||||
|
||||
const intent = state['features/recording'].startRecordingIntent;
|
||||
|
||||
const wantsRecording = Boolean(intent?.recording);
|
||||
const wantsTranscription = Boolean(intent?.transcription);
|
||||
|
||||
// No intent — nothing to coordinate.
|
||||
if (!wantsRecording && !wantsTranscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionDatas } = state['features/recording'];
|
||||
const { mode: modeConstants, status: statusConstants } = JitsiRecordingConstants;
|
||||
|
||||
// Locate the active FILE recording session (if any) and derive its state.
|
||||
const fileSession = sessionDatas.find(sd => sd.mode === modeConstants.FILE
|
||||
&& (sd.status === statusConstants.ON || sd.error));
|
||||
const recordingOn = fileSession?.status === statusConstants.ON;
|
||||
const recordingFailed = Boolean(fileSession?.error);
|
||||
const recordingResolved = !wantsRecording || recordingOn || recordingFailed;
|
||||
|
||||
// Derive transcription resolution from existing state.
|
||||
const transcriptionOn = isRecorderTranscriptionsRunning(state) || isTranscribing(state);
|
||||
const transcriptionFailed = state['features/subtitles']._hasError;
|
||||
const transcriptionResolved = !wantsTranscription || transcriptionOn || transcriptionFailed;
|
||||
|
||||
// Wait until all intended services have resolved.
|
||||
// Note: In theory (it should never happen here) wantsTranscription/wantsRecording might be false and in the same
|
||||
// time the transcriptionOn/recordingOn might be true. In this case we would play a notification no matter that
|
||||
// wantsTranscription/wantsRecording is false. This is better because the recording/transcription are on and the
|
||||
// user has to be informed. Also if this ever happens it will be noticeable and we will be able to debug/fix.
|
||||
if (!recordingResolved || !transcriptionResolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recording is on but jicofo hasn't yet delivered the initiator. Wait for it
|
||||
// so the notification text reflects who started the recording.
|
||||
if (recordingOn && (!fileSession?.initiator || !fileSession.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the intent BEFORE dispatching below to avoid a re-entrancy double
|
||||
// fire — see the matching comment in maybeNotifyRecordingStop.
|
||||
if (intent) {
|
||||
dispatch(setStartRecordingIntent(null));
|
||||
}
|
||||
|
||||
// Determine sound to play now that all intended services have resolved.
|
||||
let soundID: string | undefined;
|
||||
|
||||
if (recordingOn && transcriptionOn) {
|
||||
soundID = RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID;
|
||||
} else if (recordingOn) {
|
||||
soundID = RECORDING_ON_SOUND_ID;
|
||||
} else if (transcriptionOn) {
|
||||
soundID = TRANSCRIPTION_ON_SOUND_ID;
|
||||
}
|
||||
// If both failed — no start sound (error notifications handle it).
|
||||
|
||||
if (soundID) {
|
||||
dispatch(playSound(soundID));
|
||||
}
|
||||
|
||||
// Nudge to start the complementary service when only one was just started and the other is not
|
||||
// already running — shown to any participant that has the right to start that other service:
|
||||
// transcription needs the transcription feature; cloud recording needs moderator + the recording
|
||||
// feature. (A recording-only participant is never nudged to transcribe, nor vice versa.)
|
||||
const canStartTranscription = canAddTranscriber(state);
|
||||
const canStartRecording = isLocalParticipantModerator(state)
|
||||
&& isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false);
|
||||
const nudgeNeeded = (wantsRecording !== wantsTranscription)
|
||||
&& (wantsRecording ? (!transcriptionOn && canStartTranscription) : (!recordingOn && canStartRecording));
|
||||
const nudge = nudgeNeeded
|
||||
? getNudge(wantsRecording ? 'recording' : 'transcription', dispatch)
|
||||
: null;
|
||||
|
||||
if (recordingOn && fileSession?.initiator && fileSession.id) {
|
||||
dispatch(showStartedRecordingNotification(
|
||||
modeConstants.FILE,
|
||||
fileSession.initiator,
|
||||
fileSession.id,
|
||||
recordingOn && transcriptionOn,
|
||||
nudge ?? undefined));
|
||||
} else if (transcriptionOn && !recordingOn) {
|
||||
// Transcription-only case (recording failed or wasn't requested).
|
||||
dispatch(showNotification({
|
||||
descriptionKey: 'transcribing.on',
|
||||
titleKey: 'dialog.recording',
|
||||
...(nudge ? {
|
||||
description: nudge.descriptionText,
|
||||
customActionNameKey: [ nudge.actionNameKey ],
|
||||
customActionHandler: [ nudge.handler ]
|
||||
} : {})
|
||||
}, nudge ? NOTIFICATION_TIMEOUT_TYPE.LONG : NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether all intended services (recording and/or transcription) that
|
||||
* are being stopped have resolved, and plays the appropriate off sound and
|
||||
* notification. Mirror of {@link maybeNotifyRecordingStart}.
|
||||
*
|
||||
* Intent source: {@code stopRecordingIntent} — populated on the local side by the
|
||||
* stop dialog, and on remote observers by the metadata listener when a true→false
|
||||
* transition of isRecordingRequested / isTranscribingEnabled is seen.
|
||||
*
|
||||
* Called from:
|
||||
* - Metadata change listener on true→false transitions.
|
||||
* - Recording middleware when RECORDING_SESSION_UPDATED arrives with OFF.
|
||||
* - Transcription subscriber when isRecorderTranscriptionsRunning becomes false.
|
||||
* - Transcribing middleware when TRANSCRIBER_LEFT abruptly.
|
||||
*
|
||||
* @param {Function} dispatch - Redux dispatch.
|
||||
* @param {Function} getState - Redux getState.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function maybeNotifyRecordingStop(dispatch: IStore['dispatch'], getState: IStore['getState']) {
|
||||
const state = getState();
|
||||
const intent = state['features/recording'].stopRecordingIntent;
|
||||
|
||||
if (!intent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionDatas } = state['features/recording'];
|
||||
const { mode: modeConstants, status: statusConstants } = JitsiRecordingConstants;
|
||||
|
||||
// Recording resolves when there's no active/pending FILE session.
|
||||
const activeFileSession = sessionDatas.find(sd =>
|
||||
sd.mode === modeConstants.FILE
|
||||
&& (sd.status === statusConstants.ON || sd.status === statusConstants.PENDING));
|
||||
const recordingResolved = !intent.recording || !activeFileSession;
|
||||
|
||||
// Transcription resolves when the selector flips false.
|
||||
const transcriptionResolved = !intent.transcription || !isRecorderTranscriptionsRunning(state);
|
||||
|
||||
if (!recordingResolved || !transcriptionResolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine sounds + notification.
|
||||
let onSoundID: string | undefined;
|
||||
let offSoundID: string | undefined;
|
||||
|
||||
if (intent.recording && intent.transcription) {
|
||||
onSoundID = RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID;
|
||||
offSoundID = RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID;
|
||||
} else if (intent.recording) {
|
||||
onSoundID = RECORDING_ON_SOUND_ID;
|
||||
offSoundID = RECORDING_OFF_SOUND_ID;
|
||||
} else if (intent.transcription) {
|
||||
onSoundID = TRANSCRIPTION_ON_SOUND_ID;
|
||||
offSoundID = TRANSCRIPTION_OFF_SOUND_ID;
|
||||
}
|
||||
|
||||
// Clear the intent BEFORE dispatching below. Otherwise the first dispatch
|
||||
// re-enters StateListenerRegistry synchronously; the isRecorderTranscriptionsRunning
|
||||
// subscriber (in ../transcribing/subscriber) can fire in that nested walk
|
||||
// with an unresolved prevSel and call back into this function while intent
|
||||
// is still set — producing a duplicate notification. Reading intent into a
|
||||
// local const above keeps the branches below correct.
|
||||
dispatch(setStopRecordingIntent(null));
|
||||
|
||||
if (offSoundID) {
|
||||
// Always stop the combined sound in case both services were started together.
|
||||
dispatch(stopSound(RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID));
|
||||
if (onSoundID && onSoundID !== RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID) {
|
||||
dispatch(stopSound(onSoundID));
|
||||
}
|
||||
dispatch(playSound(offSoundID));
|
||||
}
|
||||
|
||||
if (intent.recording) {
|
||||
// Pick the most recent FILE OFF session so the "stopped by …" name
|
||||
// reflects the current stop, not a stale earlier cycle left in
|
||||
// sessionDatas. sessionDatas is append-order, so the last match wins.
|
||||
let offSession: ISessionData | undefined;
|
||||
|
||||
sessionDatas.forEach(sd => {
|
||||
if (sd.mode === modeConstants.FILE && sd.status === statusConstants.OFF) {
|
||||
offSession = sd;
|
||||
}
|
||||
});
|
||||
|
||||
const participantName = offSession?.terminator
|
||||
? getParticipantDisplayName(state, getResourceId(offSession.terminator))
|
||||
: undefined;
|
||||
|
||||
dispatch(showStoppedRecordingNotification(
|
||||
modeConstants.FILE, participantName, intent.transcription));
|
||||
} else if (intent.transcription) {
|
||||
dispatch(showNotification({
|
||||
descriptionKey: 'transcribing.off',
|
||||
titleKey: 'dialog.recording'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* StateListenerRegistry provides a reliable way to detect the leaving of a
|
||||
* conference, where we need to clean up the recording sessions.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => getCurrentConference(state),
|
||||
/* listener */ (conference, { dispatch }) => {
|
||||
if (!conference) {
|
||||
dispatch(clearRecordingSessions());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Listen for metadata changes to coordinate start/stop sound on the remote side.
|
||||
* Detects false↔true transitions for {@code isRecordingRequested} and
|
||||
* {@code isTranscribingEnabled} and seeds the corresponding intent for remote
|
||||
* observers. Local initiators already have the intent set synchronously from
|
||||
* their dialog — the {@code if (!existing)} guard prevents clobbering.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/conference'].metadata?.recording,
|
||||
/* listener */ (recordingMetadata, { dispatch, getState }, previousValue) => {
|
||||
const prevRec = Boolean(previousValue?.isRecordingRequested);
|
||||
const prevTrans = Boolean(previousValue?.isTranscribingEnabled);
|
||||
const curRec = Boolean(recordingMetadata?.isRecordingRequested);
|
||||
const curTrans = Boolean(recordingMetadata?.isTranscribingEnabled);
|
||||
|
||||
const recordingStarting = !prevRec && curRec;
|
||||
const transcriptionStarting = !prevTrans && curTrans;
|
||||
const recordingStopping = prevRec && !curRec;
|
||||
const transcriptionStopping = prevTrans && !curTrans;
|
||||
|
||||
if (recordingStarting || transcriptionStarting) {
|
||||
const existing = getState()['features/recording'].startRecordingIntent;
|
||||
|
||||
if (!existing) {
|
||||
dispatch(setStartRecordingIntent({
|
||||
recording: recordingStarting,
|
||||
transcription: transcriptionStarting
|
||||
}));
|
||||
}
|
||||
maybeNotifyRecordingStart(dispatch, getState);
|
||||
}
|
||||
|
||||
if (recordingStopping || transcriptionStopping) {
|
||||
const existing = getState()['features/recording'].stopRecordingIntent;
|
||||
|
||||
if (!existing) {
|
||||
dispatch(setStopRecordingIntent({
|
||||
recording: recordingStopping,
|
||||
transcription: transcriptionStopping
|
||||
}));
|
||||
}
|
||||
maybeNotifyRecordingStop(dispatch, getState);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* The redux middleware to handle the recorder updates in a React way.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
|
||||
let oldSessionData;
|
||||
|
||||
if (action.type === RECORDING_SESSION_UPDATED) {
|
||||
oldSessionData
|
||||
= getSessionById(getState(), action.sessionData.id);
|
||||
}
|
||||
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case APP_WILL_MOUNT:
|
||||
registerRecordingAudioFiles(dispatch);
|
||||
|
||||
break;
|
||||
|
||||
case APP_WILL_UNMOUNT:
|
||||
unregisterRecordingAudioFiles(dispatch);
|
||||
|
||||
break;
|
||||
|
||||
case CONFERENCE_JOIN_IN_PROGRESS: {
|
||||
const { conference } = action;
|
||||
|
||||
conference.on(
|
||||
JitsiConferenceEvents.RECORDER_STATE_CHANGED,
|
||||
(recorderSession: any) => {
|
||||
if (recorderSession) {
|
||||
recorderSession.getID() && dispatch(updateRecordingSessionData(recorderSession));
|
||||
if (recorderSession.getError()) {
|
||||
_showRecordingErrorNotification(recorderSession, dispatch, getState);
|
||||
} else {
|
||||
_showExplicitConsentDialog(recorderSession, dispatch, getState);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case START_LOCAL_RECORDING: {
|
||||
const { localRecording } = getState()['features/base/config'];
|
||||
const { onlySelf } = action;
|
||||
|
||||
LocalRecordingManager.startLocalRecording({
|
||||
dispatch,
|
||||
getState
|
||||
}, action.onlySelf)
|
||||
.then(() => {
|
||||
const props = {
|
||||
descriptionKey: 'recording.on',
|
||||
titleKey: 'dialog.recording'
|
||||
};
|
||||
|
||||
if (localRecording?.notifyAllParticipants && !onlySelf) {
|
||||
dispatch(playSound(RECORDING_ON_SOUND_ID));
|
||||
}
|
||||
dispatch(showNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
dispatch(showNotification({
|
||||
titleKey: 'recording.localRecordingStartWarningTitle',
|
||||
descriptionKey: 'recording.localRecordingStartWarning'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
||||
dispatch(setLocalRecordingRunning(true));
|
||||
dispatch(updateLocalRecordingStatus(true, onlySelf));
|
||||
sendAnalytics(createRecordingEvent('started', `local${onlySelf ? '.self' : ''}`));
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyRecordingStatusChanged(
|
||||
true, 'local', undefined, isRecorderTranscriptionsRunning(getState()));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Capture failed', err);
|
||||
|
||||
let descriptionKey = 'recording.error';
|
||||
|
||||
if (err.message === 'WrongSurfaceSelected') {
|
||||
descriptionKey = 'recording.surfaceError';
|
||||
|
||||
} else if (err.message === 'NoLocalStreams') {
|
||||
descriptionKey = 'recording.noStreams';
|
||||
} else if (err.message === 'NoMicTrack') {
|
||||
descriptionKey = 'recording.noMicPermission';
|
||||
}
|
||||
const props = {
|
||||
descriptionKey,
|
||||
titleKey: 'recording.failedToStart'
|
||||
};
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyRecordingStatusChanged(
|
||||
false, 'local', err.message, isRecorderTranscriptionsRunning(getState()));
|
||||
}
|
||||
|
||||
dispatch(showErrorNotification(props));
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case STOP_LOCAL_RECORDING: {
|
||||
const { localRecording } = getState()['features/base/config'];
|
||||
|
||||
if (LocalRecordingManager.isRecordingLocally()) {
|
||||
// Capture before stopping — the recorder's stop handler resets the flag.
|
||||
const wasSelfRecording = LocalRecordingManager.selfRecording.on;
|
||||
|
||||
LocalRecordingManager.stopLocalRecording();
|
||||
dispatch(setLocalRecordingRunning(false));
|
||||
dispatch(updateLocalRecordingStatus(false));
|
||||
if (localRecording?.notifyAllParticipants && !wasSelfRecording) {
|
||||
dispatch(playSound(RECORDING_OFF_SOUND_ID));
|
||||
}
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyRecordingStatusChanged(
|
||||
false, 'local', undefined, isRecorderTranscriptionsRunning(getState()));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case RECORDING_SESSION_UPDATED: {
|
||||
const state = getState();
|
||||
|
||||
// When in recorder mode no notifications are shown
|
||||
// or extra sounds are also not desired
|
||||
// but we want to indicate those in case of sip gateway
|
||||
const {
|
||||
iAmRecorder,
|
||||
iAmSipGateway
|
||||
} = state['features/base/config'];
|
||||
|
||||
if (iAmRecorder && !iAmSipGateway) {
|
||||
break;
|
||||
}
|
||||
|
||||
const updatedSessionData
|
||||
= getSessionById(state, action.sessionData.id);
|
||||
const { initiator, mode = '', terminator } = updatedSessionData ?? {};
|
||||
const { PENDING, OFF, ON } = JitsiRecordingConstants.status;
|
||||
const isRecordingStarting = updatedSessionData?.status === PENDING && oldSessionData?.status !== PENDING;
|
||||
|
||||
if (isRecordingStarting || updatedSessionData?.status === ON) {
|
||||
dispatch(hideNotification(START_RECORDING_NOTIFICATION_ID));
|
||||
}
|
||||
|
||||
if (isRecordingStarting) {
|
||||
dispatch(showPendingRecordingNotification(mode));
|
||||
break;
|
||||
}
|
||||
|
||||
dispatch(hidePendingRecordingNotification(mode));
|
||||
|
||||
if (updatedSessionData?.status === ON) {
|
||||
// We receive 2 updates of the session status ON. The first one is from jibri when it joins.
|
||||
// The second one is from jicofo which will deliver the initiator value. Since the start
|
||||
// recording notification uses the initiator value we skip the jibri update and show the
|
||||
// notification on the update from jicofo.
|
||||
// FIXME: simplify checks when the backend start sending only one status ON update containing
|
||||
// the initiator.
|
||||
if (initiator && !oldSessionData?.initiator) {
|
||||
// Initiator just became known — let maybeNotifyRecordingStart
|
||||
// decide whether to emit the notification now. It reads the
|
||||
// initiator directly from sessionDatas and gates on its presence.
|
||||
maybeNotifyRecordingStart(dispatch, getState);
|
||||
}
|
||||
|
||||
if (oldSessionData?.status !== ON) {
|
||||
sendAnalytics(createRecordingEvent('start', mode));
|
||||
|
||||
if (mode === JitsiRecordingConstants.mode.STREAM) {
|
||||
dispatch(playSound(LIVE_STREAMING_ON_SOUND_ID));
|
||||
}
|
||||
// FILE: no call here — handled by the initiator branch above with maybeNotifyRecordingStart.
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyRecordingStatusChanged(
|
||||
true, mode, undefined, isRecorderTranscriptionsRunning(state));
|
||||
}
|
||||
}
|
||||
} else if (updatedSessionData?.status === OFF && oldSessionData?.status !== OFF) {
|
||||
let duration = 0;
|
||||
|
||||
if (oldSessionData?.timestamp) {
|
||||
duration
|
||||
= (Date.now() / 1000) - oldSessionData.timestamp;
|
||||
}
|
||||
sendAnalytics(createRecordingEvent('stop', mode, duration));
|
||||
|
||||
if (mode === JitsiRecordingConstants.mode.FILE) {
|
||||
// Recording OFF is one of the resolution points — let the stop
|
||||
// coordinator decide which sound/notification to play based on
|
||||
// stopRecordingIntent (combined vs recording-only).
|
||||
maybeNotifyRecordingStop(dispatch, getState);
|
||||
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
|
||||
const participantName = terminator
|
||||
? getParticipantDisplayName(state, getResourceId(terminator))
|
||||
: undefined;
|
||||
|
||||
dispatch(showStoppedRecordingNotification(mode, participantName, false));
|
||||
dispatch(stopSound(LIVE_STREAMING_ON_SOUND_ID));
|
||||
dispatch(playSound(LIVE_STREAMING_OFF_SOUND_ID));
|
||||
}
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyRecordingStatusChanged(
|
||||
false, mode, undefined, isRecorderTranscriptionsRunning(state));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case TRACK_ADDED: {
|
||||
const { track } = action;
|
||||
|
||||
if (LocalRecordingManager.isRecordingLocally()
|
||||
&& track.mediaType === MEDIA_TYPE.AUDIO && track.local) {
|
||||
const audioTrack = track.jitsiTrack.track;
|
||||
|
||||
LocalRecordingManager.addAudioTrackToLocalRecording(audioTrack);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PARTICIPANT_UPDATED: {
|
||||
const { id, role } = action.participant;
|
||||
const state = getState();
|
||||
const localParticipant = getLocalParticipant(state);
|
||||
|
||||
if (localParticipant?.id !== id) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (role === PARTICIPANT_ROLE.MODERATOR) {
|
||||
dispatch(showStartRecordingNotification());
|
||||
}
|
||||
|
||||
return next(action);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows a notification about an error in the recording session. A
|
||||
* default notification will display if no error is specified in the passed
|
||||
* in recording session.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} session - The recorder session model from the
|
||||
* lib.
|
||||
* @param {Dispatch} dispatch - The Redux Dispatch function.
|
||||
* @param {Function} getState - The Redux getState function.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _showRecordingErrorNotification(session: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
|
||||
const mode = session.getMode();
|
||||
const error = session.getError();
|
||||
const isStreamMode = mode === JitsiMeetJS.constants.recording.mode.STREAM;
|
||||
|
||||
switch (error) {
|
||||
case JitsiMeetJS.constants.recording.error.SERVICE_UNAVAILABLE:
|
||||
dispatch(showRecordingError({
|
||||
descriptionKey: 'recording.unavailable',
|
||||
descriptionArguments: {
|
||||
serviceName: isStreamMode
|
||||
? '$t(liveStreaming.serviceName)'
|
||||
: '$t(recording.serviceName)'
|
||||
},
|
||||
titleKey: isStreamMode
|
||||
? 'liveStreaming.unavailableTitle'
|
||||
: 'recording.unavailableTitle'
|
||||
}));
|
||||
break;
|
||||
case JitsiMeetJS.constants.recording.error.RESOURCE_CONSTRAINT:
|
||||
dispatch(showRecordingError({
|
||||
descriptionKey: isStreamMode
|
||||
? 'liveStreaming.busy'
|
||||
: 'recording.busy',
|
||||
titleKey: isStreamMode
|
||||
? 'liveStreaming.busyTitle'
|
||||
: 'recording.busyTitle'
|
||||
}));
|
||||
break;
|
||||
case JitsiMeetJS.constants.recording.error.UNEXPECTED_REQUEST:
|
||||
dispatch(showRecordingWarning({
|
||||
descriptionKey: isStreamMode
|
||||
? 'liveStreaming.sessionAlreadyActive'
|
||||
: 'recording.sessionAlreadyActive',
|
||||
titleKey: isStreamMode ? 'liveStreaming.inProgress' : 'recording.inProgress'
|
||||
}));
|
||||
break;
|
||||
case JitsiMeetJS.constants.recording.error.POLICY_VIOLATION:
|
||||
dispatch(showRecordingWarning({
|
||||
descriptionKey: isStreamMode ? 'liveStreaming.policyError' : 'recording.policyError',
|
||||
titleKey: isStreamMode ? 'liveStreaming.failedToStart' : 'recording.failedToStart'
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
dispatch(showRecordingError({
|
||||
descriptionKey: isStreamMode
|
||||
? 'liveStreaming.error'
|
||||
: 'recording.error',
|
||||
titleKey: isStreamMode
|
||||
? 'liveStreaming.failedToStart'
|
||||
: 'recording.failedToStart'
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyRecordingStatusChanged(false, mode, error, isRecorderTranscriptionsRunning(getState()));
|
||||
}
|
||||
|
||||
// Recording failed — re-evaluate. maybeNotifyRecordingStart derives state from
|
||||
// sessionDatas (which now has the error) and will play transcription-only sound
|
||||
// if transcription succeeded.
|
||||
const intent = getState()['features/recording'].startRecordingIntent;
|
||||
|
||||
if (intent) {
|
||||
maybeNotifyRecordingStart(dispatch, getState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes audio and video and displays the RecordingConsentDialog when the conditions are met.
|
||||
*
|
||||
* @param {any} recorderSession - The recording session.
|
||||
* @param {Function} dispatch - The Redux dispatch function.
|
||||
* @param {Function} getState - The Redux getState function.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _showExplicitConsentDialog(recorderSession: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
|
||||
if (!shouldRequireRecordingConsent(recorderSession, getState())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the current mute state BEFORE forcing mute for consent
|
||||
// This preserves the user's intentional mute choices from prejoin or initial settings
|
||||
const state = getState();
|
||||
const audioWasMuted = state['features/base/media'].audio.muted;
|
||||
const videoWasMuted = state['features/base/media'].video.muted;
|
||||
|
||||
batch(() => {
|
||||
dispatch(markConsentRequested(recorderSession.getID()));
|
||||
dispatch(setAudioUnmutePermissions(true, true));
|
||||
dispatch(setVideoUnmutePermissions(true, true));
|
||||
dispatch(setAudioMuted(true));
|
||||
dispatch(setVideoMuted(true));
|
||||
dispatch(openDialog('RecordingConsentDialog', RecordingConsentDialog, {
|
||||
audioWasMuted,
|
||||
videoWasMuted
|
||||
}));
|
||||
});
|
||||
}
|
||||
30
react/features/recording/nudge.native.ts
Normal file
30
react/features/recording/nudge.native.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IStore } from '../app/types';
|
||||
import i18next from '../base/i18n/i18next';
|
||||
import { navigate }
|
||||
from '../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../mobile/navigation/routes';
|
||||
|
||||
import { INudge } from './actions.any';
|
||||
|
||||
/**
|
||||
* Returns the nudge notification payload for the given scenario.
|
||||
*
|
||||
* @param {string} scenario - Which service just started.
|
||||
* @param {Function} _dispatch - Unused on native (navigation handles the action).
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getNudge(scenario: 'recording' | 'transcription', _dispatch: IStore['dispatch']): INudge {
|
||||
if (scenario === 'recording') {
|
||||
return {
|
||||
descriptionText: `· ${i18next.t('recording.alsoTranscribe')}`,
|
||||
actionNameKey: 'dialog.startTranscribing',
|
||||
handler: () => navigate(screen.conference.recording)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
descriptionText: `· ${i18next.t('transcribing.alsoRecord')}`,
|
||||
actionNameKey: 'dialog.startRecording',
|
||||
handler: () => navigate(screen.conference.recording)
|
||||
};
|
||||
}
|
||||
34
react/features/recording/nudge.web.ts
Normal file
34
react/features/recording/nudge.web.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
import i18next from '../base/i18n/i18next';
|
||||
|
||||
import { INudge } from './actions.any';
|
||||
import RecordingTranscriptionDialog from './components/Recording/web/RecordingTranscriptionDialog';
|
||||
|
||||
/**
|
||||
* Returns the nudge notification payload for the given scenario.
|
||||
*
|
||||
* @param {string} scenario - Which service just started.
|
||||
* @param {Function} dispatch - The Redux dispatch function.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getNudge(scenario: 'recording' | 'transcription', dispatch: IStore['dispatch']): INudge {
|
||||
if (scenario === 'recording') {
|
||||
return {
|
||||
descriptionText: `· ${i18next.t('recording.alsoTranscribe')}`,
|
||||
actionNameKey: 'dialog.startTranscribing',
|
||||
handler: () => dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog, {
|
||||
recordAudioAndVideo: false
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
descriptionText: `· ${i18next.t('transcribing.alsoRecord')}`,
|
||||
actionNameKey: 'dialog.startRecording',
|
||||
|
||||
// recordAudioAndVideo defaults to true, which already puts the focus on the recording
|
||||
// section, so nothing extra needs to be passed here.
|
||||
handler: () => dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog))
|
||||
};
|
||||
}
|
||||
209
react/features/recording/reducer.ts
Normal file
209
react/features/recording/reducer.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import {
|
||||
CLEAR_RECORDING_SESSIONS,
|
||||
MARK_CONSENT_REQUESTED,
|
||||
RECORDING_SESSION_UPDATED,
|
||||
SET_LOCAL_RECORDING_RUNNING,
|
||||
SET_MEETING_HIGHLIGHT_BUTTON_STATE,
|
||||
SET_PENDING_RECORDING_NOTIFICATION_UID,
|
||||
SET_SELECTED_RECORDING_SERVICE,
|
||||
SET_START_RECORDING_INTENT,
|
||||
SET_START_RECORDING_NOTIFICATION_SHOWN,
|
||||
SET_STOP_RECORDING_INTENT,
|
||||
SET_STREAM_KEY
|
||||
} from './actionTypes';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
consentRequested: new Set(),
|
||||
disableHighlightMeetingMoment: false,
|
||||
pendingNotificationUids: {},
|
||||
selectedRecordingService: '',
|
||||
sessionDatas: []
|
||||
};
|
||||
|
||||
export interface ISessionData {
|
||||
error?: Error;
|
||||
id?: string;
|
||||
initiator?: { getId: Function; };
|
||||
liveStreamViewURL?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
terminator?: { getId: Function; };
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the user's intent when starting recording with or without transcription.
|
||||
* Set synchronously before any async operations begin to coordinate sound/notification timing.
|
||||
*/
|
||||
export interface IStartRecordingIntent {
|
||||
recording: boolean;
|
||||
transcription: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks what is being stopped (recording and/or transcription). Mirrors
|
||||
* IStartRecordingIntent. Used by maybeNotifyRecordingStop to coordinate the
|
||||
* off-sound/notification across the recording and transcription stop events.
|
||||
*/
|
||||
export interface IStopRecordingIntent {
|
||||
recording: boolean;
|
||||
transcription: boolean;
|
||||
}
|
||||
|
||||
export interface IRecordingState {
|
||||
consentRequested: Set<any>;
|
||||
disableHighlightMeetingMoment: boolean;
|
||||
localRecordingRunning?: boolean;
|
||||
pendingNotificationUids: {
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
selectedRecordingService: string;
|
||||
sessionDatas: Array<ISessionData>;
|
||||
startRecordingIntent?: IStartRecordingIntent | null;
|
||||
stopRecordingIntent?: IStopRecordingIntent | null;
|
||||
streamKey?: string;
|
||||
wasStartRecordingSuggested?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the RecordingConsentDialog component.
|
||||
*/
|
||||
export interface IRecordingConsentDialogProps {
|
||||
audioWasMuted?: boolean;
|
||||
videoWasMuted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the Redux store this feature stores its state in.
|
||||
*/
|
||||
const STORE_NAME = 'features/recording';
|
||||
|
||||
/**
|
||||
* Reduces the Redux actions of the feature features/recording.
|
||||
*/
|
||||
ReducerRegistry.register<IRecordingState>(STORE_NAME,
|
||||
(state = DEFAULT_STATE, action): IRecordingState => {
|
||||
switch (action.type) {
|
||||
|
||||
case CLEAR_RECORDING_SESSIONS:
|
||||
return {
|
||||
...state,
|
||||
sessionDatas: [],
|
||||
startRecordingIntent: null,
|
||||
stopRecordingIntent: null
|
||||
};
|
||||
|
||||
case MARK_CONSENT_REQUESTED:
|
||||
return {
|
||||
...state,
|
||||
consentRequested: new Set([
|
||||
...state.consentRequested,
|
||||
action.sessionId
|
||||
])
|
||||
};
|
||||
|
||||
case RECORDING_SESSION_UPDATED:
|
||||
return {
|
||||
...state,
|
||||
sessionDatas:
|
||||
_updateSessionDatas(state.sessionDatas, action.sessionData)
|
||||
};
|
||||
|
||||
case SET_PENDING_RECORDING_NOTIFICATION_UID: {
|
||||
const pendingNotificationUids = {
|
||||
...state.pendingNotificationUids
|
||||
};
|
||||
|
||||
pendingNotificationUids[action.streamType] = action.uid;
|
||||
|
||||
return {
|
||||
...state,
|
||||
pendingNotificationUids
|
||||
};
|
||||
}
|
||||
|
||||
case SET_SELECTED_RECORDING_SERVICE: {
|
||||
return {
|
||||
...state,
|
||||
selectedRecordingService: action.selectedRecordingService
|
||||
};
|
||||
}
|
||||
|
||||
case SET_STREAM_KEY:
|
||||
return {
|
||||
...state,
|
||||
streamKey: action.streamKey
|
||||
};
|
||||
|
||||
case SET_MEETING_HIGHLIGHT_BUTTON_STATE:
|
||||
return {
|
||||
...state,
|
||||
disableHighlightMeetingMoment: action.disabled
|
||||
};
|
||||
|
||||
case SET_START_RECORDING_INTENT:
|
||||
return {
|
||||
...state,
|
||||
startRecordingIntent: action.intent
|
||||
};
|
||||
|
||||
case SET_STOP_RECORDING_INTENT:
|
||||
return {
|
||||
...state,
|
||||
stopRecordingIntent: action.intent
|
||||
};
|
||||
|
||||
case SET_START_RECORDING_NOTIFICATION_SHOWN:
|
||||
return {
|
||||
...state,
|
||||
wasStartRecordingSuggested: true
|
||||
};
|
||||
|
||||
case SET_LOCAL_RECORDING_RUNNING:
|
||||
return {
|
||||
...state,
|
||||
localRecordingRunning: action.running
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates the known information on recording sessions.
|
||||
*
|
||||
* @param {Array} sessionDatas - The current sessions in the redux store.
|
||||
* @param {Object} newSessionData - The updated session data.
|
||||
* @private
|
||||
* @returns {Array} The session data with the updated session data added.
|
||||
*/
|
||||
function _updateSessionDatas(sessionDatas: ISessionData[], newSessionData: ISessionData) {
|
||||
const hasExistingSessionData = sessionDatas.find(
|
||||
sessionData => sessionData.id === newSessionData.id);
|
||||
let newSessionDatas;
|
||||
|
||||
if (hasExistingSessionData) {
|
||||
newSessionDatas = sessionDatas.map(sessionData => {
|
||||
if (sessionData.id === newSessionData.id) {
|
||||
return {
|
||||
...newSessionData
|
||||
};
|
||||
}
|
||||
|
||||
// Nothing to update for this session data so pass it back in.
|
||||
return sessionData;
|
||||
});
|
||||
} else {
|
||||
// If the session data is not present, then there is nothing to update
|
||||
// and instead it needs to be added to the known session data.
|
||||
newSessionDatas = [
|
||||
...sessionDatas,
|
||||
{ ...newSessionData }
|
||||
];
|
||||
}
|
||||
|
||||
return newSessionDatas;
|
||||
}
|
||||
55
react/features/recording/sounds.ts
Normal file
55
react/features/recording/sounds.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for when live streaming is stopped.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const LIVE_STREAMING_OFF_SOUND_FILE = 'liveStreamingOff.mp3';
|
||||
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for when a live streaming is started.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const LIVE_STREAMING_ON_SOUND_FILE = 'liveStreamingOn.mp3';
|
||||
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for when a recording is stopped.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const RECORDING_OFF_SOUND_FILE = 'recordingOff.mp3';
|
||||
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for when a recording is started.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const RECORDING_ON_SOUND_FILE = 'recordingOn.mp3';
|
||||
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for when transcription is stopped.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const TRANSCRIPTION_OFF_SOUND_FILE = 'transcriptionOff.mp3';
|
||||
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for when transcription is started.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const TRANSCRIPTION_ON_SOUND_FILE = 'transcriptionOn.mp3';
|
||||
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for when recording and transcription are stopped.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const RECORDING_AND_TRANSCRIPTION_OFF_SOUND_FILE = 'recordingAndTranscriptionOff.mp3';
|
||||
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for when recording and transcription are started.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const RECORDING_AND_TRANSCRIPTION_ON_SOUND_FILE = 'recordingAndTranscriptionOn.mp3';
|
||||
Reference in New Issue
Block a user