diff --git a/openvidu-browser/src/OpenVidu/LocalRecorder.ts b/openvidu-browser/src/OpenVidu/LocalRecorder.ts index 7e384670..1d8a2eef 100644 --- a/openvidu-browser/src/OpenVidu/LocalRecorder.ts +++ b/openvidu-browser/src/OpenVidu/LocalRecorder.ts @@ -202,6 +202,10 @@ export class LocalRecorder { this.videoPreview.id = this.id; this.videoPreview.autoplay = true; + if (platform.name === 'Safari' && platform.product === 'iPhone') { + this.videoPreview.setAttribute('playsinline', 'true'); + } + if (typeof parentElement === 'string') { this.htmlParentElementId = parentElement; diff --git a/openvidu-browser/src/OpenVidu/OpenVidu.ts b/openvidu-browser/src/OpenVidu/OpenVidu.ts index 4a10ee17..a832d5ab 100644 --- a/openvidu-browser/src/OpenVidu/OpenVidu.ts +++ b/openvidu-browser/src/OpenVidu/OpenVidu.ts @@ -77,7 +77,7 @@ export class OpenVidu { constructor() { console.info("'OpenVidu' initialized"); - if (platform.name!!.toLowerCase().indexOf('mobile') !== -1) { + if (platform.os!!.family === 'iOS' || platform.os!!.family === 'Android') { // Listen to orientationchange only on mobile browsers (window).onorientationchange = () => { this.publishers.forEach(publisher => { @@ -276,12 +276,23 @@ export class OpenVidu { */ checkSystemRequirements(): number { const browser = platform.name; - const version = platform.version; + const family = platform.os!!.family; + const userAgent = !!platform.ua ? platform.ua : navigator.userAgent; - if ((browser !== 'Chrome') && (browser !== 'Chrome Mobile') && - (browser !== 'Firefox') && (browser !== 'Firefox Mobile') && (browser !== 'Firefox for iOS') && + // Reject iPhones and iPads if not Safari ('Safari' also covers Ionic for iOS) + if (family === 'iOS' && (browser !== 'Safari' || userAgent.indexOf('CriOS') !== -1 || userAgent.indexOf('FxiOS') !== -1)) { + return 0; + } + + // Accept: Chrome (desktop and Android), Firefox (desktop and Android), Opera (desktop and Android), + // Safari (OSX and iOS), Ionic (Android and iOS) + if ( + (browser !== 'Safari') && + (browser !== 'Chrome') && (browser !== 'Chrome Mobile') && + (browser !== 'Firefox') && (browser !== 'Firefox Mobile') && (browser !== 'Opera') && (browser !== 'Opera Mobile') && - (browser !== 'Safari') && (browser !== 'Android Browser')) { + (browser !== 'Android Browser') + ) { return 0; } else { return 1; @@ -290,11 +301,18 @@ export class OpenVidu { /** - * Checks if the browser supports screen-sharing. Chrome, Firefox and Opera support screen-sharing + * Checks if the browser supports screen-sharing. Desktop Chrome, Firefox and Opera support screen-sharing * @returns 1 if the browser supports screen-sharing, 0 otherwise */ checkScreenSharingCapabilities(): number { const browser = platform.name; + const family = platform.os!!.family; + + // Reject mobile devices + if (family === 'iOS' || family === 'Android' || family === 'Windows Phone') { + return 0; + } + if ((browser !== 'Chrome') && (browser !== 'Firefox') && (browser !== 'Opera')) { return 0; } else { @@ -484,7 +502,7 @@ export class OpenVidu { (platform.name!.indexOf('Firefox') !== -1 && publisherProperties.videoSource === 'window')) { if (platform.name !== 'Chrome' && platform.name!.indexOf('Firefox') === -1 && platform.name !== 'Opera') { - const error = new OpenViduError(OpenViduErrorName.SCREEN_SHARING_NOT_SUPPORTED, 'You can only screen share in desktop Chrome and Firefox. Detected browser: ' + platform.name); + const error = new OpenViduError(OpenViduErrorName.SCREEN_SHARING_NOT_SUPPORTED, 'You can only screen share in desktop Chrome, Firefox or Opera. Detected browser: ' + platform.name); console.error(error); reject(error); } else { diff --git a/openvidu-browser/src/OpenVidu/Publisher.ts b/openvidu-browser/src/OpenVidu/Publisher.ts index 4ee144d0..eb0ee057 100644 --- a/openvidu-browser/src/OpenVidu/Publisher.ts +++ b/openvidu-browser/src/OpenVidu/Publisher.ts @@ -292,6 +292,11 @@ export class Publisher extends StreamManager { } this.videoReference = document.createElement('video'); + + if (platform.name === 'Safari' && platform.product === 'iPhone') { + this.videoReference.setAttribute('playsinline', 'true'); + } + this.videoReference.srcObject = mediaStream; this.stream.setMediaStream(mediaStream); @@ -337,8 +342,8 @@ export class Publisher extends StreamManager { }; this.screenShareResizeInterval = setInterval(() => { const firefoxSettings = mediaStream.getVideoTracks()[0].getSettings(); - const newWidth = (platform.name === 'Chrome') ? this.videoReference.videoWidth : firefoxSettings.width; - const newHeight = (platform.name === 'Chrome') ? this.videoReference.videoHeight : firefoxSettings.height; + const newWidth = (platform.name === 'Chrome' || platform.name === 'Opera') ? this.videoReference.videoWidth : firefoxSettings.width; + const newHeight = (platform.name === 'Chrome' || platform.name === 'Opera') ? this.videoReference.videoHeight : firefoxSettings.height; if (this.stream.isLocalStreamPublished && (newWidth !== this.stream.videoDimensions.width || newHeight !== this.stream.videoDimensions.height)) { diff --git a/openvidu-browser/src/OpenVidu/Session.ts b/openvidu-browser/src/OpenVidu/Session.ts index cb782052..54ebea37 100644 --- a/openvidu-browser/src/OpenVidu/Session.ts +++ b/openvidu-browser/src/OpenVidu/Session.ts @@ -152,7 +152,7 @@ export class Session implements EventDispatcher { reject(error); }); } else { - reject(new OpenViduError(OpenViduErrorName.BROWSER_NOT_SUPPORTED, 'Browser ' + platform.name + ' ' + platform.version + ' is not supported in OpenVidu')); + reject(new OpenViduError(OpenViduErrorName.BROWSER_NOT_SUPPORTED, 'Browser ' + platform.name + ' for ' + platform.os!!.family + ' is not supported in OpenVidu')); } }); } diff --git a/openvidu-browser/src/OpenVidu/StreamManager.ts b/openvidu-browser/src/OpenVidu/StreamManager.ts index 28bb1adb..883f99ca 100644 --- a/openvidu-browser/src/OpenVidu/StreamManager.ts +++ b/openvidu-browser/src/OpenVidu/StreamManager.ts @@ -112,6 +112,9 @@ export class StreamManager implements EventDispatcher { video: document.createElement('video'), id: '' }; + if (platform.name === 'Safari' && platform.product === 'iPhone') { + this.firstVideoElement.video.setAttribute('playsinline', 'true'); + } this.targetElement = targEl; this.element = targEl; } @@ -329,6 +332,11 @@ export class StreamManager implements EventDispatcher { } video.autoplay = true; video.controls = false; + + if (platform.name === 'Safari' && platform.product === 'iPhone') { + video.setAttribute('playsinline', 'true'); + } + if (!video.id) { video.id = (this.remote ? 'remote-' : 'local-') + 'video-' + this.stream.streamId; // DEPRECATED property: assign once the property id if the user provided a valid targetElement diff --git a/openvidu-browser/src/OpenViduInternal/WebRtcPeer/WebRtcPeer.ts b/openvidu-browser/src/OpenViduInternal/WebRtcPeer/WebRtcPeer.ts index 32bc0cfa..c7ac01f3 100644 --- a/openvidu-browser/src/OpenViduInternal/WebRtcPeer/WebRtcPeer.ts +++ b/openvidu-browser/src/OpenViduInternal/WebRtcPeer/WebRtcPeer.ts @@ -29,7 +29,7 @@ export interface WebRtcPeerConfiguration { onicecandidate: (event) => void; iceServers: RTCIceServer[] | undefined; mediaStream?: MediaStream; - mode?: string; // sendonly, reconly, sendrecv + mode?: 'sendonly' | 'recvonly' | 'sendrecv'; id?: string; } @@ -156,18 +156,52 @@ export class WebRtcPeer { console.debug('RTCPeerConnection constraints: ' + JSON.stringify(constraints)); - this.pc.createOffer(constraints).then(offer => { - console.debug('Created SDP offer'); - return this.pc.setLocalDescription(offer); - }).then(() => { - const localDescription = this.pc.localDescription; - if (!!localDescription) { - console.debug('Local description set', localDescription.sdp); - resolve(localDescription.sdp); - } else { - reject('Local description is not defined'); + if (platform.name === 'Safari') { + // Safari, at least on iOS just seems to support unified plan, whereas in other browsers is not yet ready and considered experimental + if (offerAudio) { + this.pc.addTransceiver('audio', { + direction: this.configuration.mode, + }); } - }).catch(error => reject(error)); + + if (offerVideo) { + this.pc.addTransceiver('video', { + direction: this.configuration.mode, + }); + } + + this.pc + .createOffer() + .then(offer => { + console.debug('Created SDP offer'); + return this.pc.setLocalDescription(offer); + }) + .then(() => { + const localDescription = this.pc.localDescription; + + if (!!localDescription) { + console.debug('Local description set', localDescription.sdp); + resolve(localDescription.sdp); + } else { + reject('Local description is not defined'); + } + }) + .catch(error => reject(error)); + } else { + this.pc.createOffer(constraints).then(offer => { + console.debug('Created SDP offer'); + return this.pc.setLocalDescription(offer); + }).then(() => { + const localDescription = this.pc.localDescription; + if (!!localDescription) { + console.debug('Local description set', localDescription.sdp); + resolve(localDescription.sdp); + } else { + reject('Local description is not defined'); + } + }) + .catch(error => reject(error)); + } }); } diff --git a/openvidu-server/src/main/java/io/openvidu/server/core/SessionManager.java b/openvidu-server/src/main/java/io/openvidu/server/core/SessionManager.java index 11bb7e24..f705b546 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/core/SessionManager.java +++ b/openvidu-server/src/main/java/io/openvidu/server/core/SessionManager.java @@ -451,7 +451,7 @@ public abstract class SessionManager { this.closeSessionAndEmptyCollections(session, reason); if (recordingService.sessionIsBeingRecorded(session.getSessionId())) { - recordingService.stopRecording(session, reason); + recordingService.stopRecording(session, null, reason); } return participants; diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSessionManager.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSessionManager.java index 3113e251..86a1395e 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSessionManager.java +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSessionManager.java @@ -175,14 +175,21 @@ public class KurentoSessionManager extends SessionManager { showTokens(); } else if (remainingParticipants.size() == 1 && openviduConfig.isRecordingModuleEnabled() && MediaMode.ROUTED.equals(session.getSessionProperties().mediaMode()) - && RecordingMode.ALWAYS.equals(session.getSessionProperties().recordingMode()) && ProtocolElements.RECORDER_PARTICIPANT_PUBLICID .equals(remainingParticipants.iterator().next().getParticipantPublicId())) { - - log.info("Last participant left. Stopping recording for session {}", sessionId); - recordingService.stopRecording(session, reason); - evictParticipant(session.getParticipantByPublicId(ProtocolElements.RECORDER_PARTICIPANT_PUBLICID), null, - null, "EVICT_RECORDER"); + if (RecordingMode.ALWAYS.equals(session.getSessionProperties().recordingMode())) { + // Immediately stop recording when last real participant left if + // RecordingMode.ALWAYS + log.info("Last participant left. Stopping recording for session {}", sessionId); + recordingService.stopRecording(session, null, reason); + evictParticipant(session.getParticipantByPublicId(ProtocolElements.RECORDER_PARTICIPANT_PUBLICID), null, + null, "EVICT_RECORDER"); + } else if (RecordingMode.MANUAL.equals(session.getSessionProperties().recordingMode())) { + // Start countdown to stop recording if RecordingMode.MANUAL (will be aborted if + // a Publisher starts before timeout) + log.info("Last participant left. Starting countdown for stopping recording of session {}", sessionId); + recordingService.initAutomaticRecordingStopThread(session.getSessionId()); + } } // Finally close websocket session if required @@ -206,14 +213,10 @@ public class KurentoSessionManager extends SessionManager { * the peer's request by sending it the SDP response (answer or updated offer) * generated by the WebRTC endpoint on the server. * - * @param participant - * Participant publishing video - * @param MediaOptions - * configuration of the stream to publish - * @param transactionId - * identifier of the Transaction - * @throws OpenViduException - * on error + * @param participant Participant publishing video + * @param MediaOptions configuration of the stream to publish + * @param transactionId identifier of the Transaction + * @throws OpenViduException on error */ @Override public void publishVideo(Participant participant, MediaOptions mediaOptions, Integer transactionId) @@ -272,16 +275,29 @@ public class KurentoSessionManager extends SessionManager { if (this.openviduConfig.isRecordingModuleEnabled() && MediaMode.ROUTED.equals(session.getSessionProperties().mediaMode()) - && RecordingMode.ALWAYS.equals(session.getSessionProperties().recordingMode()) - && !recordingService.sessionIsBeingRecorded(session.getSessionId()) && session.getActivePublishers() == 0) { - // Insecure session recording - new Thread(() -> { - recordingService.startRecording(session, - new RecordingProperties.Builder().name("") - .recordingLayout(session.getSessionProperties().defaultRecordingLayout()) - .customLayout(session.getSessionProperties().defaultCustomLayout()).build()); - }).start(); + if (RecordingMode.ALWAYS.equals(session.getSessionProperties().recordingMode()) + && !recordingService.sessionIsBeingRecorded(session.getSessionId())) { + // Insecure session recording + new Thread(() -> { + recordingService.startRecording(session, + new RecordingProperties.Builder().name("") + .recordingLayout(session.getSessionProperties().defaultRecordingLayout()) + .customLayout(session.getSessionProperties().defaultCustomLayout()).build()); + }).start(); + } else if (RecordingMode.MANUAL.equals(session.getSessionProperties().recordingMode()) + && recordingService.sessionIsBeingRecorded(session.getSessionId())) { + // Abort automatic recording stop (user published before timeout) + log.info("Participant {} published before timeout finished. Aborting automatic recording stop", + participant.getParticipantPublicId()); + boolean stopAborted = recordingService.abortAutomaticRecordingStopThread(session.getSessionId()); + if (stopAborted) { + log.info("Automatic recording stopped succesfully aborted"); + } else { + log.info("Automatic recording stopped couldn't be aborted. Recording of session {} has stopped", + session.getSessionId()); + } + } } session.newPublisher(participant); @@ -458,12 +474,10 @@ public class KurentoSessionManager extends SessionManager { * Creates a session if it doesn't already exist. The session's id will be * indicated by the session info bean. * - * @param kcSessionInfo - * bean that will be passed to the {@link KurentoClientProvider} in - * order to obtain the {@link KurentoClient} that will be used by the - * room - * @throws OpenViduException - * in case of error while creating the session + * @param kcSessionInfo bean that will be passed to the + * {@link KurentoClientProvider} in order to obtain the + * {@link KurentoClient} that will be used by the room + * @throws OpenViduException in case of error while creating the session */ public void createSession(KurentoClientSessionInfo kcSessionInfo, SessionProperties sessionProperties) throws OpenViduException { diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/ComposedRecordingService.java b/openvidu-server/src/main/java/io/openvidu/server/recording/ComposedRecordingService.java index 46f26b9b..ba40bcaa 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/ComposedRecordingService.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/ComposedRecordingService.java @@ -31,6 +31,8 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -87,6 +89,10 @@ public class ComposedRecordingService { private Map startingRecordings = new ConcurrentHashMap<>(); private Map startedRecordings = new ConcurrentHashMap<>(); private Map sessionsRecordings = new ConcurrentHashMap<>(); + private final Map> automaticRecordingStopThreads = new ConcurrentHashMap<>(); + + private ScheduledThreadPoolExecutor automaticRecordingStopExecutor = new ScheduledThreadPoolExecutor( + Runtime.getRuntime().availableProcessors()); private final String IMAGE_NAME = "openvidu/openvidu-recording"; private String IMAGE_TAG; @@ -155,10 +161,24 @@ public class ComposedRecordingService { return recording; } - public Recording stopRecording(Session session, String reason) { - Recording recording = this.sessionsRecordings.remove(session.getSessionId()); - String containerId = this.sessionsContainers.remove(session.getSessionId()); - this.startedRecordings.remove(recording.getId()); + public Recording stopRecording(Session session, String recordingId, String reason) { + Recording recording; + String containerId; + + if (session == null) { + log.warn( + "Existing recording {} does not have an active session associated. This usually means the recording" + + " layout did not join a recorded participant or the recording has been automatically" + + " stopped after last user left and timeout passed", + recordingId); + recording = this.startedRecordings.remove(recordingId); + containerId = this.sessionsContainers.remove(recording.getSessionId()); + this.sessionsRecordings.remove(recording.getSessionId()); + } else { + recording = this.sessionsRecordings.remove(session.getSessionId()); + containerId = this.sessionsContainers.remove(session.getSessionId()); + this.startedRecordings.remove(recording.getId()); + } if (containerId == null) { @@ -250,7 +270,9 @@ public class ComposedRecordingService { throw new OpenViduException(Code.RECORDING_REPORT_ERROR_CODE, "There was an error generating the metadata report file for the recording"); } - this.sessionHandler.sendRecordingStoppedNotification(session, recording, reason); + if (session != null) { + this.sessionHandler.sendRecordingStoppedNotification(session, recording, reason); + } } return recording; } @@ -465,8 +487,7 @@ public class ComposedRecordingService { private boolean isFileFromRecording(File file, String recordingId, String recordingName) { return (((recordingId + ".info").equals(file.getName())) || ((RECORDING_ENTITY_FILE + recordingId).equals(file.getName())) - || (recordingName + ".mp4").equals(file.getName()) - || (recordingId + ".jpg").equals(file.getName())); + || (recordingName + ".mp4").equals(file.getName()) || (recordingId + ".jpg").equals(file.getName())); } private String getFreeRecordingId(String sessionId, String shortSessionId) { @@ -528,4 +549,19 @@ public class ComposedRecordingService { this.IMAGE_TAG = version; } + public void initAutomaticRecordingStopThread(String sessionId) { + final String recordingId = this.sessionsRecordings.get(sessionId).getId(); + ScheduledFuture future = this.automaticRecordingStopExecutor.schedule(() -> { + log.info("Stopping recording {} after 2 minutes wait (no publisher published before timeout)", recordingId); + this.stopRecording(null, recordingId, "lastParticipantLeft"); + this.automaticRecordingStopThreads.remove(sessionId); + }, 2, TimeUnit.MINUTES); + this.automaticRecordingStopThreads.putIfAbsent(sessionId, future); + } + + public boolean abortAutomaticRecordingStopThread(String sessionId) { + ScheduledFuture future = this.automaticRecordingStopThreads.remove(sessionId); + return future.cancel(false); + } + } diff --git a/openvidu-server/src/main/java/io/openvidu/server/rest/SessionRestController.java b/openvidu-server/src/main/java/io/openvidu/server/rest/SessionRestController.java index 0a66a571..59d0d58a 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/rest/SessionRestController.java +++ b/openvidu-server/src/main/java/io/openvidu/server/rest/SessionRestController.java @@ -336,7 +336,9 @@ public class SessionRestController { recordingLayout = RecordingLayout.valueOf(recordingLayoutString); } - customLayout = (customLayout == null) ? session.getSessionProperties().defaultCustomLayout() : customLayout; + customLayout = (customLayout == null || customLayout.isEmpty()) + ? session.getSessionProperties().defaultCustomLayout() + : customLayout; Recording startedRecording = this.recordingService.startRecording(session, new RecordingProperties.Builder() .name(name).recordingLayout(recordingLayout).customLayout(customLayout).build()); @@ -368,11 +370,14 @@ public class SessionRestController { Session session = sessionManager.getSession(recording.getSessionId()); - Recording stoppedRecording = this.recordingService.stopRecording(session, "recordingStoppedByServer"); + Recording stoppedRecording = this.recordingService.stopRecording(session, recording.getId(), + "recordingStoppedByServer"); - sessionManager.evictParticipant( - session.getParticipantByPublicId(ProtocolElements.RECORDER_PARTICIPANT_PUBLICID), null, null, - "EVICT_RECORDER"); + if (session != null) { + sessionManager.evictParticipant( + session.getParticipantByPublicId(ProtocolElements.RECORDER_PARTICIPANT_PUBLICID), null, null, + "EVICT_RECORDER"); + } return new ResponseEntity<>(stoppedRecording.toJson().toString(), getResponseHeaders(), HttpStatus.OK); } diff --git a/openvidu-testapp/src/app/app.component.ts b/openvidu-testapp/src/app/app.component.ts index a57bf78d..38c3f41e 100644 --- a/openvidu-testapp/src/app/app.component.ts +++ b/openvidu-testapp/src/app/app.component.ts @@ -9,7 +9,7 @@ import { OpenviduParamsService } from './services/openvidu-params.service'; }) export class AppComponent { - openviduURL = 'https://localhost:4443/'; + openviduURL = 'https://' + window.location.hostname + ':4443/'; openviduSecret = 'MY_SECRET'; constructor(private router: Router, private openviduParamsService: OpenviduParamsService) { } diff --git a/openvidu-testapp/src/app/components/openvidu-instance/openvidu-instance.component.ts b/openvidu-testapp/src/app/components/openvidu-instance/openvidu-instance.component.ts index 42c584c1..fa8dad22 100644 --- a/openvidu-testapp/src/app/components/openvidu-instance/openvidu-instance.component.ts +++ b/openvidu-testapp/src/app/components/openvidu-instance/openvidu-instance.component.ts @@ -229,6 +229,7 @@ export class OpenviduInstanceComponent implements OnInit, OnChanges, OnDestroy { }) .catch(error => { console.log('There was an error connecting to the session:', error.code, error.message); + alert('Error connecting to the session: ' + error.message); }); } diff --git a/openvidu-testapp/src/app/services/openvidu-params.service.ts b/openvidu-testapp/src/app/services/openvidu-params.service.ts index bd4be953..84582c50 100644 --- a/openvidu-testapp/src/app/services/openvidu-params.service.ts +++ b/openvidu-testapp/src/app/services/openvidu-params.service.ts @@ -10,10 +10,10 @@ export interface OpenviduParams { export class OpenviduParamsService { params: OpenviduParams = - { - openviduUrl: 'https://localhost:4443/', - openviduSecret: 'MY_SECRET' - }; + { + openviduUrl: 'https://' + window.location.hostname + ':4443/', + openviduSecret: 'MY_SECRET' + }; newParams$ = new Subject();