diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php index 7d0dcad6..7b34a673 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -1,140 +1,172 @@ first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->deleteSession()) { + return $this->errorResponse(500, \trans('meet.session-close-error')); + } + + return response()->json([ + 'status' => 'success', + 'message' => __('meet.session-close-success'), + ]); + } /** * Listing of rooms that belong to the current user. * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = Auth::guard()->user(); $rooms = Room::where('user_id', $user->id)->orderBy('name')->get(); if (count($rooms) == 0) { // Create a room for the user list($name, $domain) = explode('@', $user->email); // Room name is limited to 16 characters by the DB schema if (strlen($name) > 16) { $name = substr($name, 0, 16); } while (Room::where('name', $name)->first()) { $name = \App\Utils::randStr(8); } $room = Room::create([ 'name' => $name, 'user_id' => $user->id ]); $rooms = collect([$room]); } $result = [ 'list' => $rooms, 'count' => count($rooms), ]; return response()->json($result); } /** * Join the room session. Each room has one owner, and the room isn't open until the owner * joins (and effectively creates the session). * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse */ public function joinRoom($id) { $room = Room::where('name', $id)->first(); // This isn't a room, bye bye if (!$room) { - return $this->errorResponse(404, \trans('meet.roomnotfound')); + return $this->errorResponse(404, \trans('meet.room-not-found')); } $user = Auth::guard()->user(); // There's no existing session if (!$room->hasSession()) { // Participants can't join the room until the session is created by the owner if (!$user || $user->id != $room->user_id) { - return $this->errorResponse(423, \trans('meet.sessionnotfound')); + return $this->errorResponse(423, \trans('meet.session-not-found')); } // The room owner can create the session on request if (empty(request()->input('init'))) { - return $this->errorResponse(424, \trans('meet.sessionnotfound')); + return $this->errorResponse(424, \trans('meet.session-not-found')); } $session = $room->createSession(); if (empty($session)) { - return $this->errorResponse(500, \trans('meet.sessioncreateerror')); + return $this->errorResponse(500, \trans('meet.session-create-error')); } } // Create session token for the current user/connection $response = $room->getSessionToken('PUBLISHER'); if (empty($response)) { - return $this->errorResponse(500, \trans('meet.sessionjoinerror')); + return $this->errorResponse(500, \trans('meet.session-join-error')); } // Create session token for screen sharing connection if (!empty(request()->input('screenShare'))) { $add_token = $room->getSessionToken('PUBLISHER'); $response['shareToken'] = $add_token['token']; } // Tell the UI who's the room owner $response['owner'] = $user && $user->id == $room->user_id; - return response()->json($response, 200); + return response()->json($response); } /** * Webhook as triggered from OpenVidu server * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function webhook(Request $request) { \Log::debug($request->getContent()); switch ((string) $request->input('event')) { case 'sessionDestroyed': // When all participants left the room OpenVidu dispatches sessionDestroyed // event. We'll remove the session reference from the database. $sessionId = $request->input('sessionId'); $room = Room::where('session_id', $sessionId)->first(); if ($room) { $room->session_id = null; $room->save(); } break; } return response('Success', 200); } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php index 9e7404ef..da291736 100644 --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -1,140 +1,166 @@ false, // No exceptions from Guzzle 'base_uri' => \config('openvidu.api_url'), 'verify' => \config('openvidu.api_verify_tls'), 'auth' => [ \config('openvidu.api_username'), \config('openvidu.api_password') ] ] ); } return self::$client; } /** * Create a OpenVidu session * * @return array|null Session data on success, NULL otherwise */ public function createSession(): ?array { $response = $this->client()->request( 'POST', "sessions", [ 'json' => [ 'mediaMode' => 'ROUTED', 'recordingMode' => 'MANUAL' ] ] ); if ($response->getStatusCode() !== 200) { $this->session_id = null; $this->save(); } $session = json_decode($response->getBody(), true); $this->session_id = $session['id']; $this->save(); return $session; } + /** + * Delete a OpenVidu session + * + * @return bool + */ + public function deleteSession(): bool + { + if (!$this->session_id) { + return true; + } + + $response = $this->client()->request( + 'DELETE', + "sessions/" . $this->session_id, + ); + + if ($response->getStatusCode() == 204) { + $this->session_id = null; + $this->save(); + + return true; + } + + return false; + } + /** * Create a OpenVidu session (connection) token * * @return array|null Token data on success, NULL otherwise */ public function getSessionToken($role = 'PUBLISHER'): ?array { $response = $this->client()->request( 'POST', 'tokens', [ 'json' => [ 'session' => $this->session_id, 'role' => $role ] ] ); if ($response->getStatusCode() == 200) { $json = json_decode($response->getBody(), true); return $json; } return null; } /** * Check if the room has an active session * * @return bool True when the session exists, False otherwise */ public function hasSession(): bool { if (!$this->session_id) { return false; } $response = $this->client()->request('GET', "sessions/{$this->session_id}"); return $response->getStatusCode() == 200; } /** * The room owner. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('\App\User', 'user_id', 'id'); } /** * Any (additional) properties of this room. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id'); } } diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js index 89075f84..746904d4 100644 --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -1,811 +1,814 @@ import anchorme from 'anchorme' import { library } from '@fortawesome/fontawesome-svg-core' import { OpenVidu } from 'openvidu-browser' function Meet(container) { let OV // OpenVidu object to initialize a session let session // Session object where the user will connect let publisher // Publisher object which the user will publish let audioEnabled = false // True if the audio track of publisher is active let videoEnabled = false // True if the video track of publisher is active let numOfVideos = 0 // Keeps track of the number of videos that are being shown let audioSource = '' // Currently selected microphone let videoSource = '' // Currently selected camera let sessionData // Room session metadata let screenOV // OpenVidu object to initialize a screen sharing session let screenSession // Session object where the user will connect for screen sharing let screenPublisher // Publisher object which the user will publish the screen sharing let publisherDefaults = { publishAudio: true, // Whether to start publishing with your audio unmuted or not publishVideo: true, // Whether to start publishing with your video enabled or not resolution: '640x480', // The resolution of your video frameRate: 30, // The frame rate of your video mirror: true // Whether to mirror your local video or not } let cameras = [] // List of user video devices let microphones = [] // List of user audio devices let connections = {} // Connected users in the session let containerWidth let containerHeight let chatCount = 0 let volumeElement let setupProps OV = new OpenVidu() screenOV = new OpenVidu() // if there's anything to do, do it here. //OV.setAdvancedConfiguration(config) // Disconnect participant when browser's window close window.addEventListener('beforeunload', () => { leaveRoom() }) window.addEventListener('resize', resize) // Public methods this.isScreenSharingSupported = isScreenSharingSupported this.joinRoom = joinRoom this.leaveRoom = leaveRoom this.setup = setup this.setupSetAudioDevice = setupSetAudioDevice this.setupSetVideoDevice = setupSetVideoDevice this.switchAudio = switchAudio this.switchScreen = switchScreen this.switchVideo = switchVideo /** * Join the room session * - * @param data Session metadata (session, token, shareToken, nickname, chatElement, menuElement) + * @param data Session metadata and event handlers (session, token, shareToken, nickname, + * chatElement, menuElement, onDestroy) */ function joinRoom(data) { resize(); volumeMeterStop() data.params = { nickname: data.nickname, // user nickname // avatar: undefined // avatar image } sessionData = data // Init a session session = OV.initSession() // On every new Stream received... session.on('streamCreated', event => { let connection = event.stream.connection let connectionId = connection.connectionId let metadata = JSON.parse(connection.data) let wrapper = addVideoWrapper(container, metadata, event.stream) // Subscribe to the Stream to receive it let subscriber = session.subscribe(event.stream, wrapper); // When the new video is added to DOM, update the page layout subscriber.on('videoElementCreated', event => { numOfVideos++ updateLayout() connections[connectionId] = { element: wrapper } // Send the current user status to the connecting user // otherwise e.g. nickname might be not up to date signalUserUpdate(connection) }) // When a video is removed from DOM, update the page layout subscriber.on('videoElementDestroyed', event => { numOfVideos-- updateLayout() delete connections[connectionId] }) }) -/* - // On every new Stream destroyed... - session.on('streamDestroyed', event => { - // Update the page layout - numOfVideos-- + + // Handle session disconnection events + session.on('sessionDisconnected', event => { + if (data.onDestroy) { + data.onDestroy(event) + } + updateLayout() }) -*/ + // Register handler for signals from other participants session.on('signal', signalEventHandler) // Connect with the token session.connect(data.token, data.params) .then(() => { data.params.publisher = true let wrapper = addVideoWrapper(container, data.params) publisher.on('videoElementCreated', event => { $(event.element).prop('muted', true) // Mute local video to avoid feedback numOfVideos++ updateLayout() }) publisher.createVideoElement(wrapper, 'PREPEND') sessionData.wrapper = wrapper // Publish the stream session.publish(publisher) }) .catch(error => { console.error('There was an error connecting to the session: ', error.message); }) // Prepare the chat setupChat() } /** * Leave the room (disconnect) */ function leaveRoom() { if (publisher) { volumeMeterStop() publisher.publishAudio(false) publisher.publishVideo(false) } if (session) { session.disconnect(); session = null } if (screenSession) { screenSession.disconnect(); screenSession = null } publisher = null } /** * Sets the audio and video devices for the session. * This will ask user for permission to access media devices. * * @param props Setup properties (videoElement, volumeElement, success, error) */ function setup(props) { setupProps = props publisher = OV.initPublisher(undefined, publisherDefaults) publisher.once('accessDenied', error => { props.error(error) }) publisher.once('accessAllowed', async () => { let mediaStream = publisher.stream.getMediaStream() let videoStream = mediaStream.getVideoTracks()[0] let audioStream = mediaStream.getAudioTracks()[0] audioEnabled = !!audioStream videoEnabled = !!videoStream volumeElement = props.volumeElement publisher.addVideoElement(props.videoElement) volumeMeterStart() const devices = await OV.getDevices() devices.forEach(device => { // device's props: deviceId, kind, label if (device.kind == 'videoinput') { cameras.push(device) if (videoStream && videoStream.label == device.label) { videoSource = device.deviceId } } else if (device.kind == 'audioinput') { microphones.push(device) if (audioStream && audioStream.label == device.label) { audioSource = device.deviceId } } }) props.success({ microphones, cameras, audioSource, videoSource, audioEnabled, videoEnabled }) }) } /** * Change the publisher audio device * * @param deviceId Device identifier string */ async function setupSetAudioDevice(deviceId) { if (!deviceId) { publisher.publishAudio(false) volumeMeterStop() audioEnabled = false } else if (deviceId == audioSource) { publisher.publishAudio(true) volumeMeterStart() audioEnabled = true } else { const mediaStream = publisher.stream.mediaStream const oldTrack = mediaStream.getAudioTracks()[0] let properties = Object.assign({}, publisherDefaults, { publishAudio: true, publishVideo: videoEnabled, audioSource: deviceId, videoSource: videoSource }) volumeMeterStop() // Note: We're not using publisher.replaceTrack() as it wasn't working for me // Stop and remove the old track if (oldTrack) { oldTrack.stop() mediaStream.removeTrack(oldTrack) } // TODO: Handle errors await OV.getUserMedia(properties) .then(async (newMediaStream) => { publisher.stream.mediaStream = newMediaStream volumeMeterStart() audioEnabled = true audioSource = deviceId }) } return audioEnabled } /** * Change the publisher video device * * @param deviceId Device identifier string */ async function setupSetVideoDevice(deviceId) { if (!deviceId) { publisher.publishVideo(false) videoEnabled = false } else if (deviceId == videoSource) { publisher.publishVideo(true) videoEnabled = true } else { const mediaStream = publisher.stream.mediaStream const oldTrack = mediaStream.getAudioTracks()[0] let properties = Object.assign({}, publisherDefaults, { publishAudio: audioEnabled, publishVideo: true, audioSource: audioSource, videoSource: deviceId }) volumeMeterStop() // Stop and remove the old track if (oldTrack) { oldTrack.stop() mediaStream.removeTrack(oldTrack) } // TODO: Handle errors await OV.getUserMedia(properties) .then(async (newMediaStream) => { publisher.stream.mediaStream = newMediaStream volumeMeterStart() videoEnabled = true videoSource = deviceId }) } return videoEnabled } /** * Setup the chat UI */ function setupChat() { // The UI elements are created in the vue template // Here we add a logic for how they work const textarea = $(sessionData.chatElement).find('textarea') const button = $(sessionData.menuElement).find('.link-chat') textarea.on('keydown', e => { if (e.keyCode == 13 && !e.shiftKey) { if (textarea.val().length) { signalChat(textarea.val()) textarea.val('') } return false } }) // Add an element for the count of unread messages on the chat button button.append('') .on('click', () => { button.find('.badge').text('') }) } /** * Signal events handler */ function signalEventHandler(signal) { let conn, data switch (signal.type) { case 'signal:userChanged': if (conn = connections[signal.from.connectionId]) { data = JSON.parse(signal.data) $(conn.element).find('.nickname > span').text(data.nickname || '') $(conn.element).find('.status-audio')[data.audioEnabled ? 'addClass' : 'removeClass']('d-none') $(conn.element).find('.status-video')[data.videoEnabled ? 'addClass' : 'removeClass']('d-none') } break case 'signal:chat': data = JSON.parse(signal.data) data.id = signal.from.connectionId pushChatMessage(data) break } } /** * Send the chat message to other participants * * @param message Message string */ function signalChat(message) { let data = { nickname: sessionData.params.nickname, message } session.signal({ data: JSON.stringify(data), type: 'chat' }) } /** * Add a message to the chat * * @param data Object with a message, nickname, id (of the connection, empty for self) */ function pushChatMessage(data) { let message = $('').text(data.message).text() // make the message secure // Format the message, convert emails and urls to links message = anchorme({ input: message, options: { attributes: { target: "_blank" }, // any link above 20 characters will be truncated // to 20 characters and ellipses at the end truncate: 20, // characters will be taken out of the middle middleTruncation: true } // TODO: anchorme is extensible, we could support // github/phabricator's markup e.g. backticks for code samples }) message = message.replace(/\r?\n/, '
') // Display the message let isSelf = data.id == publisher.stream.connection.connectionId let chat = $(sessionData.chatElement).find('.chat') let box = chat.find('.message').last() message = $('
').html(message) message.find('a').attr('rel', 'noreferrer') if (box.length && box.data('id') == data.id) { // A message from the same user as the last message, no new box needed message.appendTo(box) } else { box = $('
').data('id', data.id) .append($('
').text(data.nickname || '')) .append(message) .appendTo(chat) if (isSelf) { box.addClass('self') } } // Count unread messages if (!$(sessionData.chatElement).is('.open')) { if (!isSelf) { chatCount++ } } else { chatCount = 0 } $(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '') } /** * Send the user properties update signal to other participants * * @param connection Optional connection to which the signal will be sent * If not specified the signal is sent to all participants */ function signalUserUpdate(connection) { let data = { audioEnabled, videoEnabled, nickname: sessionData.params.nickname } // TODO: The same for screen sharing session? session.signal({ data: JSON.stringify(data), type: 'userChanged', to: connection ? [connection] : undefined }) } /** * Mute/Unmute audio for current session publisher */ function switchAudio() { audioEnabled = !audioEnabled publisher.publishAudio(audioEnabled) // TODO: Handle enabling audio if it was never enabled (e.g. user joined the room // without giving access to his mic) $(sessionData.wrapper).find('.status-audio')[audioEnabled ? 'addClass' : 'removeClass']('d-none') signalUserUpdate() return audioEnabled } /** * Mute/Unmute video for current session publisher */ function switchVideo() { videoEnabled = !videoEnabled publisher.publishVideo(videoEnabled) // TODO: Handle enabling video if it was never enabled (e.g. user joined the room // without giving access to his camera) $(sessionData.wrapper).find('.status-video')[videoEnabled ? 'addClass' : 'removeClass']('d-none') signalUserUpdate() return videoEnabled } /** * Switch on/off screen sharing */ function switchScreen(callback) { if (screenPublisher) { screenSession.unpublish(screenPublisher) screenPublisher = null if (callback) { callback(false) } return } screenConnect(callback) } /** * Detect if screen sharing is supported by the browser */ function isScreenSharingSupported() { return !!OV.checkScreenSharingCapabilities(); } /** * Create a