feat: add config.startScreenSharing
Will try to use screensharing instead of camera video from the beginning.
This commit is contained in:
parent
5b5470ec66
commit
3926d705ad
402
conference.js
402
conference.js
|
@ -78,11 +78,6 @@ let connection;
|
|||
let localAudio, localVideo;
|
||||
let initialAudioMutedState = false, initialVideoMutedState = false;
|
||||
|
||||
/**
|
||||
* Indicates whether extension external installation is in progress or not.
|
||||
*/
|
||||
let DSExternalInstallationInProgress = false;
|
||||
|
||||
import {VIDEO_CONTAINER_TYPE} from "./modules/UI/videolayout/VideoContainer";
|
||||
|
||||
/*
|
||||
|
@ -498,11 +493,14 @@ export default {
|
|||
* show guidance overlay for users on how to give access to camera and/or
|
||||
* microphone,
|
||||
* @param {string} roomName
|
||||
* @param {boolean} startScreenSharing - if <tt>true</tt> should start with
|
||||
* screensharing instead of camera video.
|
||||
* @returns {Promise.<JitsiLocalTrack[], JitsiConnection>}
|
||||
*/
|
||||
createInitialLocalTracksAndConnect(roomName) {
|
||||
createInitialLocalTracksAndConnect(roomName, startScreenSharing) {
|
||||
let audioAndVideoError,
|
||||
audioOnlyError,
|
||||
screenSharingError,
|
||||
videoOnlyError;
|
||||
|
||||
JitsiMeetJS.mediaDevices.addEventListener(
|
||||
|
@ -513,31 +511,55 @@ export default {
|
|||
);
|
||||
|
||||
// First try to retrieve both audio and video.
|
||||
let tryCreateLocalTracks = createLocalTracks(
|
||||
{ devices: ['audio', 'video'] }, true)
|
||||
.catch(err => {
|
||||
// If failed then try to retrieve only audio.
|
||||
audioAndVideoError = err;
|
||||
return createLocalTracks({ devices: ['audio'] }, true);
|
||||
})
|
||||
.catch(err => {
|
||||
audioOnlyError = err;
|
||||
let tryCreateLocalTracks;
|
||||
|
||||
// Try video only...
|
||||
return createLocalTracks({ devices: ['video'] }, true);
|
||||
})
|
||||
.catch(err => {
|
||||
videoOnlyError = err;
|
||||
// FIXME the logic about trying to go audio only on error is duplicated
|
||||
if (startScreenSharing) {
|
||||
tryCreateLocalTracks = this._createDesktopTrack()
|
||||
.then(desktopStream => {
|
||||
return createLocalTracks({ devices: ['audio'] }, true)
|
||||
.then(([audioStream]) => {
|
||||
return [desktopStream, audioStream];
|
||||
})
|
||||
.catch(error => {
|
||||
audioOnlyError = error;
|
||||
return [desktopStream];
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error('Failed to obtain desktop stream', error);
|
||||
screenSharingError = error;
|
||||
return createLocalTracks({ devices: ['audio'] }, true);
|
||||
}).catch(error => {
|
||||
audioOnlyError = error;
|
||||
return [];
|
||||
});
|
||||
} else {
|
||||
tryCreateLocalTracks = createLocalTracks(
|
||||
{devices: ['audio', 'video']}, true)
|
||||
.catch(err => {
|
||||
// If failed then try to retrieve only audio.
|
||||
audioAndVideoError = err;
|
||||
return createLocalTracks({devices: ['audio']}, true);
|
||||
})
|
||||
.catch(err => {
|
||||
audioOnlyError = err;
|
||||
|
||||
return [];
|
||||
});
|
||||
// Try video only...
|
||||
return createLocalTracks({devices: ['video']}, true);
|
||||
})
|
||||
.catch(err => {
|
||||
videoOnlyError = err;
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.all([ tryCreateLocalTracks, connect(roomName) ])
|
||||
.then(([tracks, con]) => {
|
||||
APP.store.dispatch(
|
||||
mediaPermissionPromptVisibilityChanged(false));
|
||||
if (audioAndVideoError) {
|
||||
if (audioOnlyError) {
|
||||
if (audioAndVideoError || audioOnlyError) {
|
||||
if (audioOnlyError || videoOnlyError) {
|
||||
// If both requests for 'audio' + 'video' and 'audio'
|
||||
// only failed, we assume that there is some problems
|
||||
// with user's microphone and show corresponding dialog.
|
||||
|
@ -551,6 +573,18 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
// FIXME If there was a screen sharing error or the extension
|
||||
// needs to be installed it will appear on top of eventual
|
||||
// "microphone error" dialog. That is not great, but currently
|
||||
// it's pretty hard to chain dialogs since they don't return
|
||||
// Promises.
|
||||
if (screenSharingError) {
|
||||
// FIXME if _handleScreenSharingError will be dealing with
|
||||
// installing external extension it may close previously
|
||||
// opened microphone dialog ($.prompt.close(); is called).
|
||||
this._handleScreenSharingError(screenSharingError);
|
||||
}
|
||||
|
||||
return [tracks, con];
|
||||
});
|
||||
},
|
||||
|
@ -591,7 +625,8 @@ export default {
|
|||
).then(() => {
|
||||
analytics.init();
|
||||
return this.createInitialLocalTracksAndConnect(
|
||||
options.roomName);
|
||||
options.roomName,
|
||||
config.startScreenSharing);
|
||||
}).then(([tracks, con]) => {
|
||||
tracks.forEach(track => {
|
||||
if((track.isAudioTrack() && initialAudioMutedState)
|
||||
|
@ -1197,7 +1232,6 @@ export default {
|
|||
|
||||
/**
|
||||
* Toggles between screensharing and camera video.
|
||||
* @param {boolean} [shareScreen]
|
||||
* @param {Object} [options] - Screen sharing options that will be passed to
|
||||
* createLocalTracks.
|
||||
* @param {Array<string>} [options.desktopSharingSources] - Array with the
|
||||
|
@ -1221,119 +1255,206 @@ export default {
|
|||
}
|
||||
|
||||
if (!this._untoggleScreenSharing) {
|
||||
this.videoSwitchInProgress = true;
|
||||
let externalInstallation = false;
|
||||
const didHaveVideo = Boolean(localVideo);
|
||||
const wasVideoMuted = this.videoMuted;
|
||||
|
||||
return createLocalTracks({
|
||||
desktopSharingSources: options.desktopSharingSources,
|
||||
devices: ['desktop'],
|
||||
desktopSharingExtensionExternalInstallation: {
|
||||
interval: 500,
|
||||
checkAgain: () => {
|
||||
return DSExternalInstallationInProgress;
|
||||
},
|
||||
listener: (status, url) => {
|
||||
switch(status) {
|
||||
case "waitingForExtension":
|
||||
DSExternalInstallationInProgress = true;
|
||||
externalInstallation = true;
|
||||
APP.UI.showExtensionExternalInstallationDialog(
|
||||
url);
|
||||
break;
|
||||
case "extensionFound":
|
||||
if(externalInstallation) //close the dialog
|
||||
$.prompt.close();
|
||||
break;
|
||||
default:
|
||||
//Unknown status
|
||||
}
|
||||
}
|
||||
}
|
||||
}).then(([stream]) => {
|
||||
// Stores the "untoggle" handler which remembers whether was
|
||||
// there any video before and whether was it muted.
|
||||
this._untoggleScreenSharing
|
||||
= this._turnScreenSharingOff
|
||||
.bind(this, didHaveVideo, wasVideoMuted);
|
||||
DSExternalInstallationInProgress = false;
|
||||
// close external installation dialog on success.
|
||||
if(externalInstallation)
|
||||
$.prompt.close();
|
||||
stream.on(
|
||||
TrackEvents.LOCAL_TRACK_STOPPED,
|
||||
() => {
|
||||
// If the stream was stopped during screen sharing
|
||||
// session then we should switch back to video.
|
||||
if (this.isSharingScreen){
|
||||
this._untoggleScreenSharing
|
||||
&& this._untoggleScreenSharing();
|
||||
}
|
||||
}
|
||||
);
|
||||
return this.useVideoStream(stream);
|
||||
}).then(() => {
|
||||
this.videoSwitchInProgress = false;
|
||||
JitsiMeetJS.analytics.sendEvent(
|
||||
'conference.sharingDesktop.start');
|
||||
logger.log('sharing local desktop');
|
||||
}).catch(err => {
|
||||
// close external installation dialog to show the error.
|
||||
if(externalInstallation)
|
||||
$.prompt.close();
|
||||
this.videoSwitchInProgress = false;
|
||||
|
||||
if (err.name === TrackErrors.CHROME_EXTENSION_USER_CANCELED) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
// Pawel: With this call I'm trying to preserve the original
|
||||
// behaviour although it is not clear why would we "untoggle"
|
||||
// on failure. I suppose it was to restore video in case there
|
||||
// was some problem during "this.useVideoStream(desktopStream)".
|
||||
// It's important to note that the handler will not be available
|
||||
// if we fail early on trying to get desktop media (which makes
|
||||
// sense, because the camera video is still being used, so
|
||||
// nothing to "untoggle").
|
||||
if (this._untoggleScreenSharing) {
|
||||
this._untoggleScreenSharing();
|
||||
}
|
||||
|
||||
logger.error('failed to share local desktop', err);
|
||||
|
||||
if (err.name === TrackErrors.FIREFOX_EXTENSION_NEEDED) {
|
||||
APP.UI.showExtensionRequiredDialog(
|
||||
config.desktopSharingFirefoxExtensionURL
|
||||
);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
// Handling:
|
||||
// TrackErrors.PERMISSION_DENIED
|
||||
// TrackErrors.CHROME_EXTENSION_INSTALLATION_ERROR
|
||||
// TrackErrors.GENERAL
|
||||
// and any other
|
||||
let dialogTxt;
|
||||
let dialogTitleKey;
|
||||
|
||||
if (err.name === TrackErrors.PERMISSION_DENIED) {
|
||||
dialogTxt = APP.translation.generateTranslationHTML(
|
||||
"dialog.screenSharingPermissionDeniedError");
|
||||
dialogTitleKey = "dialog.error";
|
||||
} else {
|
||||
dialogTxt = APP.translation.generateTranslationHTML(
|
||||
"dialog.failtoinstall");
|
||||
dialogTitleKey = "dialog.permissionDenied";
|
||||
}
|
||||
|
||||
APP.UI.messageHandler.openDialog(
|
||||
dialogTitleKey, dialogTxt, false);
|
||||
});
|
||||
return this._switchToScreenSharing(options);
|
||||
} else {
|
||||
return this._untoggleScreenSharing();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates desktop (screensharing) {@link JitsiLocalTrack}
|
||||
* @param {Object} [options] - Screen sharing options that will be passed to
|
||||
* createLocalTracks.
|
||||
*
|
||||
* @return {Promise.<JitsiLocalTrack>} - A Promise resolved with
|
||||
* {@link JitsiLocalTrack} for the screensharing or rejected with
|
||||
* {@link JitsiTrackError}.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_createDesktopTrack(options = {}) {
|
||||
let externalInstallation = false;
|
||||
let DSExternalInstallationInProgress = false;
|
||||
const didHaveVideo = Boolean(localVideo);
|
||||
const wasVideoMuted = this.videoMuted;
|
||||
|
||||
return createLocalTracks({
|
||||
desktopSharingSources: options.desktopSharingSources,
|
||||
devices: ['desktop'],
|
||||
desktopSharingExtensionExternalInstallation: {
|
||||
interval: 500,
|
||||
checkAgain: () => {
|
||||
return DSExternalInstallationInProgress;
|
||||
},
|
||||
listener: (status, url) => {
|
||||
switch(status) {
|
||||
case "waitingForExtension": {
|
||||
DSExternalInstallationInProgress = true;
|
||||
externalInstallation = true;
|
||||
const listener = () => {
|
||||
// Wait a little bit more just to be sure that
|
||||
// we won't miss the extension installation
|
||||
setTimeout(
|
||||
() => {
|
||||
DSExternalInstallationInProgress = false;
|
||||
}, 500);
|
||||
APP.UI.removeListener(
|
||||
UIEvents.EXTERNAL_INSTALLATION_CANCELED,
|
||||
listener);
|
||||
};
|
||||
APP.UI.addListener(
|
||||
UIEvents.EXTERNAL_INSTALLATION_CANCELED,
|
||||
listener);
|
||||
APP.UI.showExtensionExternalInstallationDialog(url);
|
||||
break;
|
||||
}
|
||||
case "extensionFound": {
|
||||
if (externalInstallation) //close the dialog
|
||||
$.prompt.close();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
//Unknown status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).then(([desktopStream])=> {
|
||||
// Stores the "untoggle" handler which remembers whether was
|
||||
// there any video before and whether was it muted.
|
||||
this._untoggleScreenSharing
|
||||
= this._turnScreenSharingOff
|
||||
.bind(this, didHaveVideo, wasVideoMuted);
|
||||
desktopStream.on(
|
||||
TrackEvents.LOCAL_TRACK_STOPPED,
|
||||
() => {
|
||||
// If the stream was stopped during screen sharing
|
||||
// session then we should switch back to video.
|
||||
if (this.isSharingScreen) {
|
||||
this._untoggleScreenSharing
|
||||
&& this._untoggleScreenSharing();
|
||||
}
|
||||
}
|
||||
);
|
||||
// close external installation dialog on success.
|
||||
if (externalInstallation) {
|
||||
$.prompt.close();
|
||||
}
|
||||
return desktopStream;
|
||||
}, error => {
|
||||
DSExternalInstallationInProgress = false;
|
||||
// close external installation dialog on success.
|
||||
if (externalInstallation) {
|
||||
$.prompt.close();
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Tries to switch to the screenshairng mode by disposing camera stream and
|
||||
* replacing it with a desktop one.
|
||||
*
|
||||
* @param {Object} [options] - Screen sharing options that will be passed to
|
||||
* createLocalTracks.
|
||||
*
|
||||
* @return {Promise} - A Promise resolved if the operation succeeds or
|
||||
* rejected with some unknown type of error in case it fails. Promise will
|
||||
* be rejected immediately if {@link videoSwitchInProgress} is true.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_switchToScreenSharing(options = {}) {
|
||||
if (this.videoSwitchInProgress) {
|
||||
return Promise.reject('Switch in progress.');
|
||||
}
|
||||
|
||||
this.videoSwitchInProgress = true;
|
||||
return this._createDesktopTrack(options).then(stream => {
|
||||
return this.useVideoStream(stream);
|
||||
}).then(() => {
|
||||
this.videoSwitchInProgress = false;
|
||||
JitsiMeetJS.analytics.sendEvent('conference.sharingDesktop.start');
|
||||
logger.log('sharing local desktop');
|
||||
}).catch(error => {
|
||||
this.videoSwitchInProgress = false;
|
||||
// Pawel: With this call I'm trying to preserve the original
|
||||
// behaviour although it is not clear why would we "untoggle"
|
||||
// on failure. I suppose it was to restore video in case there
|
||||
// was some problem during "this.useVideoStream(desktopStream)".
|
||||
// It's important to note that the handler will not be available
|
||||
// if we fail early on trying to get desktop media (which makes
|
||||
// sense, because the camera video is still being used, so
|
||||
// nothing to "untoggle").
|
||||
if (this._untoggleScreenSharing) {
|
||||
this._untoggleScreenSharing();
|
||||
}
|
||||
|
||||
// FIXME the code inside of _handleScreenSharingError is
|
||||
// asynchronous, but does not return a Promise and is not part of
|
||||
// the current Promise chain.
|
||||
this._handleScreenSharingError(error);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles {@link JitsiTrackError} returned by the lib-jitsi-meet when
|
||||
* trying to create screensharing track. It will either do nothing if
|
||||
* the dialog was canceled on user's request or display inline installation
|
||||
* dialog and ask the user to install the extension, once the extension is
|
||||
* installed it will switch the conference to screensharing. The last option
|
||||
* is that an unrecoverable error dialog will be displayed.
|
||||
* @param {JitsiTrackError} error - The error returned by
|
||||
* {@link _createDesktopTrack} Promise.
|
||||
* @private
|
||||
*/
|
||||
_handleScreenSharingError(error) {
|
||||
if (error.name === TrackErrors.CHROME_EXTENSION_USER_CANCELED) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error('failed to share local desktop', error);
|
||||
|
||||
if (error.name === TrackErrors.CHROME_EXTENSION_USER_GESTURE_REQUIRED) {
|
||||
// If start with screen sharing the extension will fail to install
|
||||
// (if not found), because the request has been triggered by the
|
||||
// script. Show a dialog which asks user to click "install" and try
|
||||
// again switching to the screen sharing.
|
||||
APP.UI.showExtensionInlineInstallationDialog(
|
||||
() => {
|
||||
this.toggleScreenSharing();
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
} else if (error.name === TrackErrors.FIREFOX_EXTENSION_NEEDED) {
|
||||
APP.UI.showExtensionRequiredDialog(
|
||||
config.desktopSharingFirefoxExtensionURL
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handling:
|
||||
// TrackErrors.PERMISSION_DENIED
|
||||
// TrackErrors.CHROME_EXTENSION_INSTALLATION_ERROR
|
||||
// TrackErrors.GENERAL
|
||||
// and any other
|
||||
let dialogTxt;
|
||||
let dialogTitleKey;
|
||||
|
||||
if (error.name === TrackErrors.PERMISSION_DENIED) {
|
||||
dialogTxt = APP.translation.generateTranslationHTML(
|
||||
"dialog.screenSharingPermissionDeniedError");
|
||||
dialogTitleKey = "dialog.error";
|
||||
} else {
|
||||
dialogTxt = APP.translation.generateTranslationHTML(
|
||||
"dialog.failtoinstall");
|
||||
dialogTitleKey = "dialog.permissionDenied";
|
||||
}
|
||||
|
||||
APP.UI.messageHandler.openDialog(dialogTitleKey, dialogTxt, false);
|
||||
},
|
||||
/**
|
||||
* Setup interaction between conference and UI.
|
||||
*/
|
||||
|
@ -1619,17 +1740,6 @@ export default {
|
|||
APP.UI.updateDTMFSupport(isDTMFSupported);
|
||||
});
|
||||
|
||||
APP.UI.addListener(UIEvents.EXTERNAL_INSTALLATION_CANCELED, () => {
|
||||
// Wait a little bit more just to be sure that we won't miss the
|
||||
// extension installation
|
||||
setTimeout(() => DSExternalInstallationInProgress = false, 500);
|
||||
});
|
||||
APP.UI.addListener(UIEvents.OPEN_EXTENSION_STORE, (url) => {
|
||||
window.open(
|
||||
url, "extension_store_window",
|
||||
"resizable,scrollbars=yes,status=1");
|
||||
});
|
||||
|
||||
APP.UI.addListener(UIEvents.AUDIO_MUTED, muteLocalAudio);
|
||||
APP.UI.addListener(UIEvents.VIDEO_MUTED, muted => {
|
||||
if (this.isAudioOnly() && !muted) {
|
||||
|
|
|
@ -69,6 +69,7 @@ var config = { // eslint-disable-line no-unused-vars
|
|||
// page redirection when call is hangup
|
||||
disableSimulcast: false,
|
||||
// requireDisplayName: true, // Forces the participants that doesn't have display name to enter it when they enter the room.
|
||||
startScreenSharing: false, // Will try to start with screensharing instead of camera
|
||||
// startAudioMuted: 10, // every participant after the Nth will start audio muted
|
||||
// startVideoMuted: 10, // every participant after the Nth will start video muted
|
||||
// defaultLanguage: "en",
|
||||
|
|
|
@ -332,6 +332,8 @@
|
|||
"goToStore": "Go to the webstore",
|
||||
"externalInstallationTitle": "Extension required",
|
||||
"externalInstallationMsg": "You need to install our desktop sharing extension.",
|
||||
"inlineInstallationMsg": "You need to install our desktop sharing extension.",
|
||||
"inlineInstallExtension": "Install now",
|
||||
"muteParticipantTitle": "Mute this participant?",
|
||||
"muteParticipantBody": "You won't be able to unmute them, but they can unmute themselves at any time.",
|
||||
"muteParticipantButton": "Mute",
|
||||
|
|
|
@ -1158,15 +1158,34 @@ UI.showExtensionRequiredDialog = function (url) {
|
|||
* @param url {string} the url of the extension.
|
||||
*/
|
||||
UI.showExtensionExternalInstallationDialog = function (url) {
|
||||
let openedWindow = null;
|
||||
|
||||
let submitFunction = function(e,v){
|
||||
if (v) {
|
||||
e.preventDefault();
|
||||
eventEmitter.emit(UIEvents.OPEN_EXTENSION_STORE, url);
|
||||
if (openedWindow === null || openedWindow.closed) {
|
||||
openedWindow
|
||||
= window.open(
|
||||
url,
|
||||
"extension_store_window",
|
||||
"resizable,scrollbars=yes,status=1");
|
||||
} else {
|
||||
openedWindow.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let closeFunction = function () {
|
||||
eventEmitter.emit(UIEvents.EXTERNAL_INSTALLATION_CANCELED);
|
||||
let closeFunction = function (e, v) {
|
||||
if (openedWindow) {
|
||||
// Ideally we would close the popup, but this does not seem to work
|
||||
// on Chrome. Leaving it uncommented in case it could work
|
||||
// in some version.
|
||||
openedWindow.close();
|
||||
openedWindow = null;
|
||||
}
|
||||
if (!v) {
|
||||
eventEmitter.emit(UIEvents.EXTERNAL_INSTALLATION_CANCELED);
|
||||
}
|
||||
};
|
||||
|
||||
messageHandler.openTwoButtonDialog({
|
||||
|
@ -1179,6 +1198,36 @@ UI.showExtensionExternalInstallationDialog = function (url) {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows a dialog which asks user to install the extension. This one is
|
||||
* displayed after installation is triggered from the script, but fails because
|
||||
* it must be initiated by user gesture.
|
||||
* @param callback {function} function to be executed after user clicks
|
||||
* the install button - it should make another attempt to install the extension.
|
||||
*/
|
||||
UI.showExtensionInlineInstallationDialog = function (callback) {
|
||||
let submitFunction = function(e,v){
|
||||
if (v) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
let closeFunction = function (e, v) {
|
||||
if (!v) {
|
||||
eventEmitter.emit(UIEvents.EXTERNAL_INSTALLATION_CANCELED);
|
||||
}
|
||||
};
|
||||
|
||||
messageHandler.openTwoButtonDialog({
|
||||
titleKey: 'dialog.externalInstallationTitle',
|
||||
msgKey: 'dialog.inlineInstallationMsg',
|
||||
leftButtonKey: 'dialog.inlineInstallExtension',
|
||||
submitFunction,
|
||||
loadedFunction: $.noop,
|
||||
closeFunction
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Shows dialog with combined information about camera and microphone errors.
|
||||
|
|
|
@ -97,12 +97,6 @@ export default {
|
|||
// changed.
|
||||
RESOLUTION_CHANGED: "UI.resolution_changed",
|
||||
|
||||
/**
|
||||
* Notifies that the button "Go to webstore" is pressed on the dialog for
|
||||
* external extension installation.
|
||||
*/
|
||||
OPEN_EXTENSION_STORE: "UI.open_extension_store",
|
||||
|
||||
/**
|
||||
* Notifies that the button "Cancel" is pressed on the dialog for
|
||||
* external extension installation.
|
||||
|
|
Loading…
Reference in New Issue