Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2533022
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
121 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php
index e26c2f68..99930635 100644
--- a/src/app/Http/Controllers/API/V4/OpenViduController.php
+++ b/src/app/Http/Controllers/API/V4/OpenViduController.php
@@ -1,590 +1,590 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\OpenVidu\Connection;
use App\OpenVidu\Room;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class OpenViduController extends Controller
{
public const AUTH_HEADER = 'X-Meet-Auth-Token';
/**
* Accept the room join request.
*
* @param string $id Room identifier (name)
* @param string $reqid Request identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function acceptJoinRequest($id, $reqid)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Only the moderator can do it
if (!$this->isModerator($room)) {
return $this->errorResponse(403);
}
if (!$room->requestAccept($reqid)) {
return $this->errorResponse(500, \trans('meet.session-request-accept-error'));
}
return response()->json(['status' => 'success']);
}
/**
* Deny the room join request.
*
* @param string $id Room identifier (name)
* @param string $reqid Request identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function denyJoinRequest($id, $reqid)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Only the moderator can do it
if (!$this->isModerator($room)) {
return $this->errorResponse(403);
}
if (!$room->requestDeny($reqid)) {
return $this->errorResponse(500, \trans('meet.session-request-deny-error'));
}
return response()->json(['status' => 'success']);
}
/**
* Close the room session.
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse
*/
public function closeRoom($id)
{
$room = Room::where('name', $id)->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'),
]);
}
/**
* Create a connection for screen sharing.
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse
*/
public function createConnection($id)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
$connection = $this->getConnectionFromRequest();
if (
!$connection
|| $connection->session_id != $room->session_id
|| ($connection->role & Room::ROLE_PUBLISHER) == 0
) {
return $this->errorResponse(403);
}
$response = $room->getSessionToken(Room::ROLE_SCREEN);
return response()->json(['status' => 'success', 'token' => $response['token']]);
}
/**
* Dismiss the participant/connection from the session.
*
* @param string $id Room identifier (name)
* @param string $conn Connection identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function dismissConnection($id, $conn)
{
$connection = Connection::where('id', $conn)->first();
// There's no such connection, bye bye
if (!$connection || $connection->room->name != $id) {
return $this->errorResponse(404, \trans('meet.connection-not-found'));
}
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
return $this->errorResponse(403);
}
if (!$connection->dismiss()) {
return $this->errorResponse(500, \trans('meet.connection-dismiss-error'));
}
return response()->json(['status' => 'success']);
}
/**
* Listing of rooms that belong to the authenticated 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 (with a random and unique name)
while (true) {
$name = strtolower(\App\Utils::randStr(3, 3, '-'));
if (!Room::where('name', $name)->count()) {
break;
}
}
$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();
// Room does not exist, or the owner is deleted
if (!$room || !$room->owner) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Check if there's still a valid beta entitlement for the room owner
$sku = \App\Sku::where('title', 'meet')->first();
if ($sku && !$room->owner->entitlements()->where('sku_id', $sku->id)->first()) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
$user = Auth::guard()->user();
$isOwner = $user && $user->id == $room->user_id;
$init = !empty(request()->input('init'));
// There's no existing session
if (!$room->hasSession()) {
// Participants can't join the room until the session is created by the owner
if (!$isOwner) {
return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]);
}
// The room owner can create the session on request
if (!$init) {
return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]);
}
$session = $room->createSession();
if (empty($session)) {
return $this->errorResponse(500, \trans('meet.session-create-error'));
}
}
$password = (string) $room->getSetting('password');
$config = [
'locked' => $room->getSetting('locked') === 'true',
'nomedia' => $room->getSetting('nomedia') === 'true',
'password' => $isOwner ? $password : '',
'requires_password' => !$isOwner && strlen($password),
];
$response = ['config' => $config];
// Validate room password
if (!$isOwner && strlen($password)) {
$request_password = request()->input('password');
if ($request_password !== $password) {
return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]);
}
}
// Handle locked room
if (!$isOwner && $config['locked']) {
$nickname = request()->input('nickname');
$picture = request()->input('picture');
$requestId = request()->input('requestId');
$request = $requestId ? $room->requestGet($requestId) : null;
$error = \trans('meet.session-room-locked-error');
// Request already has been processed (not accepted yet, but it could be denied)
if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) {
if (!$request) {
if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) {
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
if (empty($picture)) {
$svg = file_get_contents(resource_path('images/user.svg'));
$picture = 'data:image/svg+xml;base64,' . base64_encode($svg);
} elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) {
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
// TODO: Resize when big/make safe the user picture?
$request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture];
if (!$room->requestSave($requestId, $request)) {
// FIXME: should we use error code 500?
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
// Send the request (signal) to the owner
$result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR);
}
return $this->errorResponse(422, $error, $response + ['code' => 327]);
}
}
// Initialize connection tokens
if ($init) {
// Choose the connection role
- $canPublish = empty($config['nomedia']) && !empty(request()->input('canPublish'));
+ $canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner);
$role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER;
if ($isOwner) {
$role |= Room::ROLE_MODERATOR;
$role |= Room::ROLE_OWNER;
}
// Create session token for the current user/connection
$response = $room->getSessionToken($role);
if (empty($response)) {
return $this->errorResponse(500, \trans('meet.session-join-error'));
}
// Get up-to-date connections metadata
$response['connections'] = $room->getSessionConnections();
$response_code = 200;
$response['role'] = $role;
$response['config'] = $config;
} else {
$response_code = 422;
$response['code'] = 322;
}
return response()->json($response, $response_code);
}
/**
* Set the domain configuration.
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setRoomConfig($id)
{
$room = Room::where('name', $id)->first();
// Room does not exist, or the owner is deleted
if (!$room || !$room->owner) {
return $this->errorResponse(404);
}
$user = Auth::guard()->user();
// Only room owner can configure the room
if ($user->id != $room->user_id) {
return $this->errorResponse(403);
}
$input = request()->input();
$errors = [];
foreach ($input as $key => $value) {
switch ($key) {
case 'password':
if ($value === null || $value === '') {
$input[$key] = null;
} else {
// TODO: Do we have to validate the password in any way?
}
break;
case 'locked':
$input[$key] = $value ? 'true' : null;
break;
case 'nomedia':
$input[$key] = $value ? 'true' : null;
break;
default:
$errors[$key] = \trans('meet.room-unsupported-option-error');
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if (!empty($input)) {
$room->setSettings($input);
}
return response()->json([
'status' => 'success',
'message' => \trans('meet.room-setconfig-success'),
]);
}
/**
* Update the participant/connection parameters (e.g. role).
*
* @param string $id Room identifier (name)
* @param string $conn Connection identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function updateConnection($id, $conn)
{
$connection = Connection::where('id', $conn)->first();
// There's no such connection, bye bye
if (!$connection || $connection->room->name != $id) {
return $this->errorResponse(404, \trans('meet.connection-not-found'));
}
foreach (request()->input() as $key => $value) {
switch ($key) {
case 'hand':
// Only possible on user's own connection(s)
if (!$this->isSelfConnection($connection)) {
return $this->errorResponse(403);
}
if ($value) {
// Store current time, so we know the order in the queue
$connection->metadata = ['hand' => time()] + $connection->metadata;
} else {
$connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
}
break;
case 'language':
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
return $this->errorResponse(403);
}
if ($value) {
if (preg_match('/^[a-z]{2}$/', $value)) {
$connection->metadata = ['language' => $value] + $connection->metadata;
}
} else {
$connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
}
break;
case 'role':
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
return $this->errorResponse(403);
}
// The 'owner' role is not assignable
if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) {
return $this->errorResponse(403);
} elseif (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) {
return $this->errorResponse(403);
}
// The room owner has always a 'moderator' role
if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) {
$value |= Room::ROLE_MODERATOR;
}
// Promotion to publisher? Put the user hand down
if ($value & Room::ROLE_PUBLISHER && !($connection->role & Room::ROLE_PUBLISHER)) {
$connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
}
// Non-publisher cannot be a language interpreter
if (!($value & Room::ROLE_PUBLISHER)) {
$connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
}
$connection->{$key} = $value;
break;
}
}
// The connection observer will send a signal to everyone when needed
$connection->save();
return response()->json(['status' => 'success']);
}
/**
* 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();
}
// Remove all connections
// Note: We could remove connections one-by-one via the 'participantLeft' event
// but that could create many INSERTs when the session (with many participants) ends
// So, it is better to remove them all in a single INSERT.
Connection::where('session_id', $sessionId)->delete();
break;
}
return response('Success', 200);
}
/**
* Check if current user is a moderator for the specified room.
*
* @param \App\OpenVidu\Room $room The room
*
* @return bool True if the current user is the room moderator
*/
protected function isModerator(Room $room): bool
{
$user = Auth::guard()->user();
// The room owner is a moderator
if ($user && $user->id == $room->user_id) {
return true;
}
// Moderator's authentication via the extra request header
if (
($connection = $this->getConnectionFromRequest())
&& $connection->session_id === $room->session_id
&& $connection->role & Room::ROLE_MODERATOR
) {
return true;
}
return false;
}
/**
* Check if current user "owns" the specified connection.
*
* @param \App\OpenVidu\Connection $connection The connection
*
* @return bool
*/
protected function isSelfConnection(Connection $connection): bool
{
return ($conn = $this->getConnectionFromRequest())
&& $conn->id === $connection->id;
}
/**
* Get the connection object for the token in current request headers.
* It will also validate the token.
*
* @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid)
*/
protected function getConnectionFromRequest()
{
// Authenticate the user via the extra request header
if ($token = request()->header(self::AUTH_HEADER)) {
list($connId, ) = explode(':', base64_decode($token), 2);
if (
($connection = Connection::find($connId))
&& $connection->metadata['authToken'] === $token
) {
return $connection;
}
}
return null;
}
}
diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
index e1a4b029..ec3a7538 100644
--- a/src/resources/js/meet/app.js
+++ b/src/resources/js/meet/app.js
@@ -1,1671 +1,1672 @@
import anchorme from 'anchorme'
import { library } from '@fortawesome/fontawesome-svg-core'
import { OpenVidu } from 'openvidu-browser'
class Roles {
static get SUBSCRIBER() { return 1 << 0; }
static get PUBLISHER() { return 1 << 1; }
static get MODERATOR() { return 1 << 2; }
static get SCREEN() { return 1 << 3; }
static get OWNER() { return 1 << 4; }
}
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 audioActive = false // True if the audio track of the publisher is active
let videoActive = false // True if the video track of the publisher is active
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 publishersContainer
let subscribersContainer
let scrollStop
OV = ovInit()
// 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.setupStart = setupStart
this.setupStop = setupStop
this.setupSetAudioDevice = setupSetAudioDevice
this.setupSetVideoDevice = setupSetVideoDevice
this.switchAudio = switchAudio
this.switchChannel = switchChannel
this.switchScreen = switchScreen
this.switchVideo = switchVideo
this.updateSession = updateSession
/**
* Initialize OpenVidu instance
*/
function ovInit()
{
let ov = new OpenVidu()
// If there's anything to do, do it here.
//ov.setAdvancedConfiguration(config)
// Disable all logging except errors
// ov.enableProdMode()
return ov
}
/**
* Join the room session
*
* @param data Session metadata and event handlers:
* token - OpenVidu token for the main connection,
* shareToken - OpenVidu token for screen-sharing connection,
* nickname - Participant name,
* role - connection (participant) role(s),
* connections - Optional metadata for other users connections (current state),
* channel - Selected interpreted language channel (two-letter language code)
* languages - Supported languages (code-to-label map)
* chatElement - DOM element for the chat widget,
* counterElement - DOM element for the participants counter,
* menuElement - DOM element of the room toolbar,
* queueElement - DOM element for the Q&A queue (users with a raised hand)
* onSuccess - Callback for session connection (join) success
* onError - Callback for session connection (join) error
* onDestroy - Callback for session disconnection event,
* onDismiss - Callback for Dismiss action,
* onJoinRequest - Callback for join request,
* onConnectionChange - Callback for participant changes, e.g. role update,
* onSessionDataUpdate - Callback for current user connection update,
* onMediaSetup - Called when user clicks the Media setup button
*/
function joinRoom(data) {
// Create a container for subscribers and publishers
publishersContainer = $('<div id="meet-publishers">').appendTo(container).get(0)
subscribersContainer = $('<div id="meet-subscribers">').appendTo(container).get(0)
resize();
volumeMeterStop()
data.params = {
nickname: data.nickname, // user nickname
// avatar: undefined // avatar image
}
- // TODO: Make sure all supported callbacks exist, so we don't have to check
- // their existence everywhere anymore
+ // Make sure all supported callbacks exist, so we don't have to check
+ // their existence everywhere anymore
+ let events = ['Success', 'Error', 'Destroy', 'Dismiss', 'JoinRequest', 'ConnectionChange',
+ 'SessionDataUpdate', 'MediaSetup']
+
+ events.map(event => 'on' + event).forEach(event => {
+ if (!data[event]) {
+ data[event] = () => {}
+ }
+ })
sessionData = data
// Init a session
session = OV.initSession()
// Handle connection creation events
session.on('connectionCreated', event => {
// Ignore the current user connection
if (event.connection.role) {
return
}
// This is the first event executed when a user joins in.
// We'll create the video wrapper here, which can be re-used
// in 'streamCreated' event handler.
let metadata = connectionData(event.connection)
const connId = metadata.connectionId
// The connection metadata here is the initial metadata set on
// connection initialization. There's no way to update it via OpenVidu API.
// So, we merge the initial connection metadata with up-to-dated one that
// we got from our database.
if (sessionData.connections && connId in sessionData.connections) {
Object.assign(metadata, sessionData.connections[connId])
}
metadata.element = participantCreate(metadata)
connections[connId] = metadata
// Send the current user status to the connecting user
// otherwise e.g. nickname might be not up to date
signalUserUpdate(event.connection)
})
session.on('connectionDestroyed', event => {
let connectionId = event.connection.connectionId
let conn = connections[connectionId]
if (conn) {
// Remove elements related to the participant
connectionHandDown(connectionId)
$(conn.element).remove()
delete connections[connectionId]
}
resize()
})
// On every new Stream received...
session.on('streamCreated', event => {
let connectionId = event.stream.connection.connectionId
let metadata = connections[connectionId]
let props = {
// Prepend the video element so it is always before the watermark element
insertMode: 'PREPEND'
}
// Subscribe to the Stream to receive it
let subscriber = session.subscribe(event.stream, metadata.element, props);
Object.assign(metadata, {
audioActive: event.stream.audioActive,
videoActive: event.stream.videoActive,
videoDimensions: event.stream.videoDimensions
})
subscriber.on('videoElementCreated', event => {
$(event.element).prop({
tabindex: -1
})
resize()
})
// Update the wrapper controls/status
participantUpdate(metadata.element, metadata)
})
// Stream properties changes e.g. audio/video muted/unmuted
session.on('streamPropertyChanged', event => {
let connectionId = event.stream.connection.connectionId
let metadata = connections[connectionId]
if (session.connection.connectionId == connectionId) {
metadata = sessionData
metadata.audioActive = audioActive
metadata.videoActive = videoActive
}
if (metadata) {
metadata[event.changedProperty] = event.newValue
if (event.changedProperty == 'videoDimensions') {
resize()
} else {
participantUpdate(metadata.element, metadata)
}
}
})
// Handle session disconnection events
session.on('sessionDisconnected', event => {
- if (data.onDestroy) {
- data.onDestroy(event)
- }
-
+ data.onDestroy(event)
session = null
resize()
})
// Handle signals from all participants
session.on('signal', signalEventHandler)
// Connect with the token
session.connect(data.token, data.params)
.then(() => {
- if (data.onSuccess) {
- data.onSuccess()
- }
+ data.onSuccess()
let params = {
connectionId: session.connection.connectionId,
role: data.role,
audioActive,
videoActive
}
params = Object.assign({}, data.params, params)
publisher.on('videoElementCreated', event => {
$(event.element).prop({
muted: true, // Mute local video to avoid feedback
disablePictureInPicture: true, // this does not work in Firefox
tabindex: -1
})
resize()
})
let wrapper = participantCreate(params)
if (data.role & Roles.PUBLISHER) {
publisher.createVideoElement(wrapper, 'PREPEND')
session.publish(publisher)
}
sessionData.element = wrapper
// Create Q&A queue from the existing connections with rised hand.
// Here we expect connections in a proper queue order
Object.keys(data.connections || {}).forEach(key => {
let conn = data.connections[key]
if (conn.hand) {
conn.connectionId = key
connectionHandUp(conn)
}
})
sessionData.channels = getChannels(data.connections)
// Inform the vue component, so it can update some UI controls
- if (sessionData.channels.length && sessionData.onSessionDataUpdate) {
+ if (sessionData.channels.length) {
sessionData.onSessionDataUpdate(sessionData)
}
})
.catch(error => {
console.error('There was an error connecting to the session: ', error.message);
-
- if (data.onError) {
- data.onError(error)
- }
+ data.onError(error)
})
// Prepare the chat
setupChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom() {
if (publisher) {
volumeMeterStop()
// Release any media
let mediaStream = publisher.stream.getMediaStream()
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop())
}
publisher = null
}
if (session) {
session.disconnect();
session = null
}
if (screenSession) {
screenSession.disconnect();
screenSession = 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, onSuccess, onError)
*/
function setupStart(props) {
// Note: After changing media permissions in Chrome/Firefox a page refresh is required.
// That means that in a scenario where you first blocked access to media devices
// and then allowed it we can't ask for devices list again and expect a different
// result than before.
// That's why we do not bother, and return ealy when we open the media setup dialog.
if (publisher) {
volumeMeterStart()
return
}
publisher = OV.initPublisher(undefined, publisherDefaults)
publisher.once('accessDenied', error => {
props.onError(error)
})
publisher.once('accessAllowed', async () => {
let mediaStream = publisher.stream.getMediaStream()
let videoStream = mediaStream.getVideoTracks()[0]
let audioStream = mediaStream.getAudioTracks()[0]
audioActive = !!audioStream
videoActive = !!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.onSuccess({
microphones,
cameras,
audioSource,
videoSource,
audioActive,
videoActive
})
})
}
/**
* Stop the setup "process", cleanup after it.
*/
function setupStop() {
volumeMeterStop()
}
/**
* Change the publisher audio device
*
* @param deviceId Device identifier string
*/
async function setupSetAudioDevice(deviceId) {
if (!deviceId) {
publisher.publishAudio(false)
volumeMeterStop()
audioActive = false
} else if (deviceId == audioSource) {
publisher.publishAudio(true)
volumeMeterStart()
audioActive = true
} else {
const mediaStream = publisher.stream.mediaStream
const properties = Object.assign({}, publisherDefaults, {
publishAudio: true,
publishVideo: videoActive,
audioSource: deviceId,
videoSource: videoSource
})
volumeMeterStop()
// Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
mediaStream.getAudioTracks().forEach(track => {
track.stop()
mediaStream.removeTrack(track)
})
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
await replaceTrack(newMediaStream.getAudioTracks()[0])
volumeMeterStart()
audioActive = true
audioSource = deviceId
})
}
return audioActive
}
/**
* Change the publisher video device
*
* @param deviceId Device identifier string
*/
async function setupSetVideoDevice(deviceId) {
if (!deviceId) {
publisher.publishVideo(false)
videoActive = false
} else if (deviceId == videoSource) {
publisher.publishVideo(true)
videoActive = true
} else {
const mediaStream = publisher.stream.mediaStream
const properties = Object.assign({}, publisherDefaults, {
publishAudio: audioActive,
publishVideo: true,
audioSource: audioSource,
videoSource: deviceId
})
volumeMeterStop()
// Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
mediaStream.getVideoTracks().forEach(track => {
track.stop()
mediaStream.removeTrack(track)
})
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
await replaceTrack(newMediaStream.getVideoTracks()[0])
volumeMeterStart()
videoActive = true
videoSource = deviceId
})
}
return videoActive
}
/**
* A way to switch tracks in a stream.
* Note: This is close to what publisher.replaceTrack() does but it does not
* require the session.
* Note: The old track needs to be removed before OV.getUserMedia() call,
* otherwise we get "Concurrent mic process limit" error.
*/
function replaceTrack(track) {
const stream = publisher.stream
const replaceMediaStreamTrack = () => {
stream.mediaStream.addTrack(track);
if (session) {
session.sendVideoData(publisher.stream.streamManager, 5, true, 5);
}
}
// Fix a bug in Chrome where you would start hearing yourself after audio device change
// https://github.com/OpenVidu/openvidu/issues/449
publisher.videoReference.muted = true
return new Promise((resolve, reject) => {
if (stream.isLocalStreamPublished) {
// Only if the Publisher has been published it is necessary to call the native
// Web API RTCRtpSender.replaceTrack()
const senders = stream.getRTCPeerConnection().getSenders()
let sender
if (track.kind === 'video') {
sender = senders.find(s => !!s.track && s.track.kind === 'video')
} else {
sender = senders.find(s => !!s.track && s.track.kind === 'audio')
}
if (!sender) return
sender.replaceTrack(track).then(() => {
replaceMediaStreamTrack()
resolve()
}).catch(error => {
reject(error)
})
} else {
// Publisher not published. Simply modify local MediaStream tracks
replaceMediaStreamTrack()
resolve()
}
})
}
/**
* 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 chat = $(sessionData.chatElement).find('.chat').get(0)
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('<span class="badge badge-dark blinker">')
.on('click', () => {
button.find('.badge').text('')
chatCount = 0
// When opening the chat scroll it to the bottom, or we shouldn't?
scrollStop = false
chat.scrollTop = chat.scrollHeight
})
$(chat).on('scroll', event => {
// Detect manual scrollbar moves, disable auto-scrolling until
// the scrollbar is positioned on the element bottom again
scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight
})
}
/**
* Signal events handler
*/
function signalEventHandler(signal) {
let conn, data
let connId = signal.from ? signal.from.connectionId : null
switch (signal.type) {
case 'signal:userChanged':
// TODO: Use 'signal:connectionUpdate' for nickname updates?
if (conn = connections[connId]) {
data = JSON.parse(signal.data)
conn.nickname = data.nickname
participantUpdate(conn.element, conn)
nicknameUpdate(data.nickname, connId)
}
break
case 'signal:chat':
data = JSON.parse(signal.data)
data.id = connId
pushChatMessage(data)
break
case 'signal:joinRequest':
// accept requests from the server only
- if (!connId && sessionData.onJoinRequest) {
+ if (!connId) {
sessionData.onJoinRequest(JSON.parse(signal.data))
}
break
case 'signal:connectionUpdate':
// accept requests from the server only
if (!connId) {
data = JSON.parse(signal.data)
connectionUpdate(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 = $('<span>').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/, '<br>')
// Display the message
let isSelf = data.id == session.connectionId
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('<div>').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 = $('<div class="message">').data('id', data.id)
.append($('<div class="nickname">').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 : '')
// Scroll the chat element to the end
if (!scrollStop) {
chat.get(0).scrollTop = chat.get(0).scrollHeight
}
}
/**
* 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 = {
nickname: sessionData.params.nickname
}
session.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
// The same nickname for screen sharing session
if (screenSession) {
screenSession.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
}
}
/**
* Switch interpreted language channel
*
* @param channel Two-letter language code
*/
function switchChannel(channel) {
sessionData.channel = channel
// Mute/unmute all connections depending on the selected channel
participantUpdateAll()
}
/**
* Mute/Unmute audio for current session publisher
*/
function switchAudio() {
// TODO: If user has no devices or denied access to them in the setup,
// the button will just not work. Find a way to make it working
// after user unlocks his devices. For now he has to refresh
// the page and join the room again.
if (microphones.length) {
try {
publisher.publishAudio(!audioActive)
audioActive = !audioActive
} catch (e) {
console.error(e)
}
}
return audioActive
}
/**
* Mute/Unmute video for current session publisher
*/
function switchVideo() {
// TODO: If user has no devices or denied access to them in the setup,
// the button will just not work. Find a way to make it working
// after user unlocks his devices. For now he has to refresh
// the page and join the room again.
if (cameras.length) {
try {
publisher.publishVideo(!videoActive)
videoActive = !videoActive
} catch (e) {
console.error(e)
}
}
return videoActive
}
/**
* Switch on/off screen sharing
*/
function switchScreen(callback) {
if (screenPublisher) {
// Note: This is what the original openvidu-call app does.
// It is probably better for performance reasons to close the connection,
// than to use unpublish() and keep the connection open.
screenSession.disconnect()
screenSession = null
screenPublisher = null
if (callback) {
// Note: Disconnecting invalidates the token, we have to inform the vue component
// to update UI state (and be prepared to request a new token).
callback(false)
}
return
}
screenConnect(callback)
}
/**
* Detect if screen sharing is supported by the browser
*/
function isScreenSharingSupported() {
return !!OV.checkScreenSharingCapabilities();
}
/**
* Update participant connection state
*/
function connectionUpdate(data) {
let conn = connections[data.connectionId]
let refresh = false
let handUpdate = conn => {
if ('hand' in data && data.hand != conn.hand) {
if (data.hand) {
connectionHandUp(conn)
} else {
connectionHandDown(data.connectionId)
}
}
}
// It's me
if (session.connection.connectionId == data.connectionId) {
const rolePublisher = data.role && data.role & Roles.PUBLISHER
const roleModerator = data.role && data.role & Roles.MODERATOR
const isPublisher = sessionData.role & Roles.PUBLISHER
const isModerator = sessionData.role & Roles.MODERATOR
// demoted to a subscriber
if ('role' in data && isPublisher && !rolePublisher) {
session.unpublish(publisher)
// FIXME: There's a reference in OpenVidu to a video element that should not
// exist anymore. It causes issues when we try to do publish/unpublish
// sequence multiple times in a row. So, we're clearing the reference here.
let videos = publisher.stream.streamManager.videos
publisher.stream.streamManager.videos = videos.filter(video => video.video.parentNode != null)
}
handUpdate(sessionData)
// merge the changed data into internal session metadata object
sessionData = Object.assign({}, sessionData, data, { audioActive, videoActive })
// update the participant element
sessionData.element = participantUpdate(sessionData.element, sessionData)
// promoted/demoted to/from a moderator
if ('role' in data) {
// Update all participants, to enable/disable the popup menu
refresh = (!isModerator && roleModerator) || (isModerator && !roleModerator)
}
// promoted to a publisher
if ('role' in data && !isPublisher && rolePublisher) {
publisher.createVideoElement(sessionData.element, 'PREPEND')
session.publish(publisher).then(() => {
sessionData.audioActive = publisher.stream.audioActive
sessionData.videoActive = publisher.stream.videoActive
- if (sessionData.onSessionDataUpdate) {
- sessionData.onSessionDataUpdate(sessionData)
- }
+ sessionData.onSessionDataUpdate(sessionData)
})
// Open the media setup dialog
// Note: If user didn't give permission to media before joining the room
// he will not be able to use them now. Changing permissions requires
// a page refresh.
// Note: In Firefox I'm always being asked again for media permissions.
// It does not happen in Chrome. In Chrome the cam/mic will be just re-used.
// I.e. streaming starts automatically.
// It might make sense to not start streaming automatically in any cirmustances,
// display the dialog and wait until user closes it, but this would be
// a bigger refactoring.
- if (sessionData.onMediaSetup) {
- sessionData.onMediaSetup()
- }
+ sessionData.onMediaSetup()
}
} else if (conn) {
handUpdate(conn)
// merge the changed data into internal session metadata object
Object.keys(data).forEach(key => { conn[key] = data[key] })
conn.element = participantUpdate(conn.element, conn)
}
// Update channels list
sessionData.channels = getChannels(connections)
// The channel user was using has been removed (or rather the participant stopped being an interpreter)
if (sessionData.channel && !sessionData.channels.includes(sessionData.channel)) {
sessionData.channel = null
refresh = true
}
if (refresh) {
participantUpdateAll()
}
// Inform the vue component, so it can update some UI controls
- if (sessionData.onSessionDataUpdate) {
- sessionData.onSessionDataUpdate(sessionData)
- }
+ sessionData.onSessionDataUpdate(sessionData)
}
/**
* Handler for Hand-Up "signal"
*/
function connectionHandUp(connection) {
connection.isSelf = session.connection.connectionId == connection.connectionId
let element = $(nicknameWidget(connection))
participantUpdate(element, connection)
element.attr('id', 'qa' + connection.connectionId)
.appendTo($(sessionData.queueElement).show())
setTimeout(() => element.addClass('widdle'), 50)
}
/**
* Handler for Hand-Down "signal"
*/
function connectionHandDown(connectionId) {
let list = $(sessionData.queueElement)
list.find('#qa' + connectionId).remove();
if (!list.find('.meet-nickname').length) {
list.hide();
}
}
/**
* Update participant nickname in the UI
*
* @param nickname Nickname
* @param connectionId Connection identifier of the user
*/
function nicknameUpdate(nickname, connectionId) {
if (connectionId) {
$(sessionData.chatElement).find('.chat').find('.message').each(function() {
let elem = $(this)
if (elem.data('id') == connectionId) {
elem.find('.nickname').text(nickname || '')
}
})
$(sessionData.queueElement).find('#qa' + connectionId + ' .content').text(nickname || '')
}
}
/**
* Create a participant element in the matrix. Depending on the connection role
* parameter it will be a video element wrapper inside the matrix or a simple
* tag-like element on the subscribers list.
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*
* @return The element
*/
function participantCreate(params, content) {
let element
params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId
if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) {
// publishers and shared screens
element = publisherCreate(params, content)
} else {
// subscribers and language interpreters
element = subscriberCreate(params, content)
}
setTimeout(resize, 50);
return element
}
/**
* Create a <video> element wrapper with controls
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*/
function publisherCreate(params, content) {
let isScreen = params.role & Roles.SCREEN
// Create the element
let wrapper = $(
'<div class="meet-video">'
+ svgIcon('user', 'fas', 'watermark')
+ '<div class="controls">'
+ '<button type="button" class="btn btn-link link-setup hidden" title="Media setup">' + svgIcon('cog') + '</button>'
+ '<button type="button" class="btn btn-link link-audio hidden" title="Mute audio">' + svgIcon('volume-mute') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="Full screen">' + svgIcon('expand') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen open hidden" title="Full screen">' + svgIcon('compress') + '</button>'
+ '</div>'
+ '<div class="status">'
+ '<span class="bg-warning status-audio hidden">' + svgIcon('microphone-slash') + '</span>'
+ '<span class="bg-warning status-video hidden">' + svgIcon('video-slash') + '</span>'
+ '</div>'
+ '</div>'
)
// Append the nickname widget
wrapper.find('.controls').before(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
if (isScreen) {
wrapper.addClass('screen')
}
if (params.isSelf) {
- if (sessionData.onMediaSetup) {
- wrapper.find('.link-setup').removeClass('hidden')
- .click(() => sessionData.onMediaSetup())
- }
+ wrapper.find('.link-setup').removeClass('hidden').click(() => sessionData.onMediaSetup())
} else {
// Enable audio mute button
wrapper.find('.link-audio').removeClass('hidden')
.on('click', e => {
let video = wrapper.find('video')[0]
video.muted = !video.muted
wrapper.find('.link-audio')[video.muted ? 'addClass' : 'removeClass']('text-danger')
})
}
participantUpdate(wrapper, params, true)
// Fullscreen control
if (document.fullscreenEnabled) {
wrapper.find('.link-fullscreen.closed').removeClass('hidden')
.on('click', () => {
wrapper.get(0).requestFullscreen()
})
wrapper.find('.link-fullscreen.open')
.on('click', () => {
document.exitFullscreen()
})
wrapper.on('fullscreenchange', () => {
// const enabled = document.fullscreenElement
wrapper.find('.link-fullscreen.closed').toggleClass('hidden')
wrapper.find('.link-fullscreen.open').toggleClass('hidden')
wrapper.toggleClass('fullscreen')
})
}
// Remove the subscriber element, if exists
$('#subscriber-' + params.connectionId).remove()
let prio = params.isSelf || (isScreen && !$(publishersContainer).children('.screen').length)
return wrapper[prio ? 'prependTo' : 'appendTo'](publishersContainer)
.attr('id', 'publisher-' + params.connectionId)
.get(0)
}
/**
* Update the publisher/subscriber element controls
*
* @param wrapper The wrapper element
* @param params Connection metadata/params
*/
function participantUpdate(wrapper, params, noupdate) {
const element = $(wrapper)
const isModerator = sessionData.role & Roles.MODERATOR
const isSelf = session.connection.connectionId == params.connectionId
const rolePublisher = params.role & Roles.PUBLISHER
const roleModerator = params.role & Roles.MODERATOR
const roleScreen = params.role & Roles.SCREEN
const roleOwner = params.role & Roles.OWNER
const roleInterpreter = rolePublisher && !!params.language
if (!noupdate && !roleScreen) {
const isPublisher = element.is('.meet-video')
// Publisher-to-interpreter or vice-versa, move element to the subscribers list or vice-versa,
// but keep the existing video element
if (
!isSelf
&& element.find('video').length
&& ((roleInterpreter && isPublisher) || (!roleInterpreter && !isPublisher && rolePublisher))
) {
wrapper = participantCreate(params, element.find('video'))
element.remove()
return wrapper
}
// Handle publisher-to-subscriber and subscriber-to-publisher change
if (
!roleInterpreter
&& (rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)
) {
element.remove()
return participantCreate(params)
}
}
let muted = false
let video = element.find('video')[0]
// When a channel is selected - mute everyone except the interpreter of the language.
// When a channel is not selected - mute language interpreters only
if (sessionData.channel) {
muted = !(roleInterpreter && params.language == sessionData.channel)
} else {
muted = roleInterpreter
}
if (muted && !isSelf) {
element.find('.status-audio').removeClass('hidden')
element.find('.link-audio').addClass('hidden')
} else {
element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden')
if (!isSelf) {
element.find('.link-audio').removeClass('hidden')
}
muted = !params.audioActive || isSelf
}
element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden')
if (video) {
video.muted = muted
}
if ('nickname' in params) {
element.find('.meet-nickname > .content').text(params.nickname)
}
if (isSelf) {
element.addClass('self')
}
if (isModerator) {
element.addClass('moderated')
}
const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf);
const withMenu = isSelf || (isModerator && !roleOwner)
// TODO: This probably could be better done with css
let elements = {
'.dropdown-menu': withMenu,
'.permissions': withPerm,
'.interpreting': withPerm && rolePublisher,
'svg.moderator': roleModerator,
'svg.user': !roleModerator && !roleInterpreter,
'svg.interpreter': !roleModerator && roleInterpreter
}
Object.keys(elements).forEach(key => {
element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden')
})
element.find('.action-role-publisher input').prop('checked', rolePublisher)
element.find('.action-role-moderator input').prop('checked', roleModerator)
.prop('disabled', roleOwner)
element.find('.interpreting select').val(roleInterpreter ? params.language : '')
return wrapper
}
/**
* Update/refresh state of all participants' elements
*/
function participantUpdateAll() {
Object.keys(connections).forEach(key => {
const conn = connections[key]
participantUpdate(conn.element, conn)
})
}
/**
* Create a tag-like element for a subscriber participant
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*/
function subscriberCreate(params, content) {
// Create the element
let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
participantUpdate(wrapper, params, true)
return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
.attr('id', 'subscriber-' + params.connectionId)
.get(0)
}
/**
* Create a tag-like nickname widget
*
* @param object params Connection metadata/params
*/
function nicknameWidget(params) {
let languages = []
// Append languages selection options
Object.keys(sessionData.languages).forEach(code => {
languages.push(`<option value="${code}">${sessionData.languages[code]}</option>`)
})
// Create the element
let element = $(
'<div class="dropdown">'
+ '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ '<span class="content"></span>'
+ '<span class="icon">'
+ svgIcon('user', null, 'user')
+ svgIcon('crown', null, 'moderator hidden')
+ svgIcon('headphones', null, 'interpreter hidden')
+ '</span>'
+ '</a>'
+ '<div class="dropdown-menu">'
+ '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
+ '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ '<div class="dropdown-divider permissions"></div>'
+ '<div class="permissions">'
+ '<h6 class="dropdown-header">Permissions</h6>'
+ '<label class="dropdown-item action-role-publisher custom-control custom-switch">'
+ '<input type="checkbox" class="custom-control-input">'
+ ' <span class="custom-control-label">Audio & Video publishing</span>'
+ '</label>'
+ '<label class="dropdown-item action-role-moderator custom-control custom-switch">'
+ '<input type="checkbox" class="custom-control-input">'
+ ' <span class="custom-control-label">Moderation</span>'
+ '</label>'
+ '</div>'
+ '<div class="dropdown-divider interpreting"></div>'
+ '<div class="interpreting">'
+ '<h6 class="dropdown-header">Language interpreter</h6>'
+ '<div class="ml-4 mr-4"><select class="custom-select">'
+ '<option value="">- none -</option>'
+ languages.join('')
+ '</select></div>'
+ '</div>'
+ '</div>'
+ '</div>'
)
let nickname = element.find('.meet-nickname')
.addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
.attr({title: 'Options', 'data-toggle': 'dropdown'})
.dropdown({boundary: container.parentNode})
if (params.isSelf) {
// Add events for nickname change
let editable = element.find('.content')[0]
let editableEnable = () => {
editable.contentEditable = true
editable.focus()
}
let editableUpdate = () => {
editable.contentEditable = false
sessionData.params.nickname = editable.innerText
signalUserUpdate()
nicknameUpdate(editable.innerText, session.connection.connectionId)
}
element.find('.action-nickname').on('click', editableEnable)
element.find('.action-dismiss').remove()
$(editable).on('blur', editableUpdate)
.on('keydown', e => {
// Enter or Esc
if (e.keyCode == 13 || e.keyCode == 27) {
editableUpdate()
return false
}
// Do not propagate the event, so it does not interfere with our
// keyboard shortcuts
e.stopPropagation()
})
} else {
element.find('.action-nickname').remove()
element.find('.action-dismiss').on('click', e => {
- if (sessionData.onDismiss) {
- sessionData.onDismiss(params.connectionId)
- }
+ sessionData.onDismiss(params.connectionId)
})
}
let connectionRole = () => {
if (params.isSelf) {
return sessionData.role
}
if (params.connectionId in connections) {
return connections[params.connectionId].role
}
return 0
}
// Don't close the menu on permission change
element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() })
- if (sessionData.onConnectionChange) {
- element.find('.action-role-publisher input').on('change', e => {
- const enabled = e.target.checked
- let role = connectionRole()
+ element.find('.action-role-publisher input').on('change', e => {
+ const enabled = e.target.checked
+ let role = connectionRole()
- if (enabled) {
- role |= Roles.PUBLISHER
- } else {
- role |= Roles.SUBSCRIBER
- if (role & Roles.PUBLISHER) {
- role ^= Roles.PUBLISHER
- }
+ if (enabled) {
+ role |= Roles.PUBLISHER
+ } else {
+ role |= Roles.SUBSCRIBER
+ if (role & Roles.PUBLISHER) {
+ role ^= Roles.PUBLISHER
}
+ }
- sessionData.onConnectionChange(params.connectionId, { role })
- })
+ sessionData.onConnectionChange(params.connectionId, { role })
+ })
- element.find('.action-role-moderator input').on('change', e => {
- const enabled = e.target.checked
- let role = connectionRole()
+ element.find('.action-role-moderator input').on('change', e => {
+ const enabled = e.target.checked
+ let role = connectionRole()
- if (enabled) {
- role |= Roles.MODERATOR
- } else if (role & Roles.MODERATOR) {
- role ^= Roles.MODERATOR
- }
+ if (enabled) {
+ role |= Roles.MODERATOR
+ } else if (role & Roles.MODERATOR) {
+ role ^= Roles.MODERATOR
+ }
- sessionData.onConnectionChange(params.connectionId, { role })
- })
+ sessionData.onConnectionChange(params.connectionId, { role })
+ })
- element.find('.interpreting select')
- .on('change', e => {
- const language = $(e.target).val()
- sessionData.onConnectionChange(params.connectionId, { language })
- element.find('.meet-nickname').dropdown('hide')
- })
- .on('click', e => {
- // Prevents from closing the dropdown menu on click
- e.stopPropagation()
- })
- }
+ element.find('.interpreting select')
+ .on('change', e => {
+ const language = $(e.target).val()
+ sessionData.onConnectionChange(params.connectionId, { language })
+ element.find('.meet-nickname').dropdown('hide')
+ })
+ .on('click', e => {
+ // Prevents from closing the dropdown menu on click
+ e.stopPropagation()
+ })
return element.get(0)
}
/**
* Window onresize event handler (updates room layout)
*/
function resize() {
- containerWidth = publishersContainer.offsetWidth
- containerHeight = publishersContainer.offsetHeight
+ if (publishersContainer) {
+ updateLayout()
+ }
- updateLayout()
$(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile')
}
/**
* Update the room "matrix" layout
*/
function updateLayout() {
let publishers = $(publishersContainer).find('.meet-video')
let numOfVideos = publishers.length
if (sessionData && sessionData.counterElement) {
sessionData.counterElement.innerHTML = Object.keys(connections).length + 1
}
if (!numOfVideos) {
+ subscribersContainer.style.minHeight = 'auto'
return
}
+ let allHeight = container.offsetHeight
+ let scrollHeight = subscribersContainer.scrollHeight
+ let containerWidth = publishersContainer.offsetWidth
+ let containerHeight = publishersContainer.offsetHeight
+ let limit = Math.ceil(allHeight * 0.25) // max subscribers list height
+
+ // Fix subscribers list height
+ if (subscribersContainer.offsetHeight <= scrollHeight) {
+ limit = Math.min(scrollHeight, limit)
+ subscribersContainer.style.minHeight = limit + 'px'
+ containerHeight = allHeight - limit
+ } else {
+ subscribersContainer.style.minHeight = 'auto'
+ }
+
let css, rows, cols, height, padding = 0
// Make the first screen sharing tile big
let screenVideo = publishers.filter('.screen').find('video').get(0)
if (screenVideo) {
let element = screenVideo.parentNode
let connId = element.id.replace(/^publisher-/, '')
let connection = connections[connId]
// We know the shared screen video dimensions, we can calculate
// width/height of the tile in the matrix
if (connection && connection.videoDimensions) {
let screenWidth = connection.videoDimensions.width
let screenHeight = containerHeight
// TODO: When the shared window is minimized the width/height is set to 1 (or 2)
// - at least on my system. We might need to handle this case nicer. Right now
// it create a 1-2px line on the left of the matrix - not a big issue.
// TODO: Make the 0.666 factor bigger for wide screen and small number of participants?
let maxWidth = Math.ceil(containerWidth * 0.666)
if (screenWidth > maxWidth) {
screenWidth = maxWidth
}
// Set the tile position and size
$(element).css({
width: screenWidth + 'px',
height: screenHeight + 'px',
position: 'absolute',
top: 0,
left: 0
})
padding = screenWidth + 'px'
// Now the estate for the rest of participants is what's left on the right side
containerWidth -= screenWidth
publishers = publishers.not(element)
numOfVideos -= 1
}
}
// Compensate the shared screen estate with a padding
$(publishersContainer).css('padding-left', padding)
const factor = containerWidth / containerHeight
if (factor >= 16/9) {
if (numOfVideos <= 3) {
rows = 1
} else if (numOfVideos <= 8) {
rows = 2
} else if (numOfVideos <= 15) {
rows = 3
} else if (numOfVideos <= 20) {
rows = 4
} else {
rows = 5
}
cols = Math.ceil(numOfVideos / rows)
} else {
if (numOfVideos == 1) {
cols = 1
} else if (numOfVideos <= 4) {
cols = 2
} else if (numOfVideos <= 9) {
cols = 3
} else if (numOfVideos <= 16) {
cols = 4
} else if (numOfVideos <= 25) {
cols = 5
} else {
cols = 6
}
rows = Math.ceil(numOfVideos / cols)
if (rows < cols && containerWidth < containerHeight) {
cols = rows
rows = Math.ceil(numOfVideos / cols)
}
}
// console.log('factor=' + factor, 'num=' + numOfVideos, 'cols = '+cols, 'rows=' + rows);
// Update all tiles (except the main shared screen) in the matrix
publishers.css({
width: (containerWidth / cols) + 'px',
// Height must be in pixels to make object-fit:cover working
height: (containerHeight / rows) + 'px'
})
}
/**
* Initialize screen sharing session/publisher
*/
function screenConnect(callback) {
if (!sessionData.shareToken) {
return false
}
let gotSession = !!screenSession
if (!screenOV) {
screenOV = ovInit()
}
// Init screen sharing session
if (!gotSession) {
screenSession = screenOV.initSession();
}
let successFunc = function() {
screenSession.publish(screenPublisher)
screenSession.on('sessionDisconnected', event => {
callback(false)
screenSession = null
screenPublisher = null
})
if (callback) {
callback(true)
}
}
let errorFunc = function() {
screenPublisher = null
if (callback) {
callback(false, true)
}
}
// Init the publisher
let params = {
videoSource: 'screen',
publishAudio: false
}
screenPublisher = screenOV.initPublisher(null, params)
screenPublisher.once('accessAllowed', (event) => {
if (gotSession) {
successFunc()
} else {
screenSession.connect(sessionData.shareToken, sessionData.params)
.then(() => {
successFunc()
})
.catch(error => {
console.error('There was an error connecting to the session:', error.code, error.message);
errorFunc()
})
}
})
screenPublisher.once('accessDenied', () => {
console.info('ScreenShare: Access Denied')
errorFunc()
})
}
/**
* Create an svg element (string) for a FontAwesome icon
*
* @todo Find if there's a "official" way to do this
*/
function svgIcon(name, type, className) {
// Note: the library will contain definitions for all icons registered elswhere
const icon = library.definitions[type || 'fas'][name]
let attrs = {
'class': 'svg-inline--fa',
'aria-hidden': true,
focusable: false,
role: 'img',
xmlns: 'http://www.w3.org/2000/svg',
viewBox: `0 0 ${icon[0]} ${icon[1]}`
}
if (className) {
attrs['class'] += ' ' + className
}
return $(`<svg><path fill="currentColor" d="${icon[4]}"></path></svg>`)
.attr(attrs)
.get(0).outerHTML
}
/**
* A way to update some session data, after you joined the room
*
* @param data Same input as for joinRoom()
*/
function updateSession(data) {
sessionData.shareToken = data.shareToken
}
/**
* A handler for volume level change events
*/
function volumeChangeHandler(event) {
let value = 100 + Math.min(0, Math.max(-100, event.value.newValue))
let color = 'lime'
const bar = volumeElement.firstChild
if (value >= 70) {
color = '#ff3300'
} else if (value >= 50) {
color = '#ff9933'
}
bar.style.height = value + '%'
bar.style.background = color
}
/**
* Start the volume meter
*/
function volumeMeterStart() {
if (publisher && volumeElement) {
publisher.on('streamAudioVolumeChange', volumeChangeHandler)
}
}
/**
* Stop the volume meter
*/
function volumeMeterStop() {
if (publisher && volumeElement) {
publisher.off('streamAudioVolumeChange')
volumeElement.firstChild.style.height = 0
}
}
function connectionData(connection) {
// Note: we're sending a json from two sources (server-side when
// creating a token/connection, and client-side when joining the session)
// OpenVidu is unable to merge these two objects into one, for it it is only
// two strings, so it puts a "%/%" separator in between, we'll replace it with comma
// to get one parseable json object
let data = JSON.parse(connection.data.replace('}%/%{', ','))
data.connectionId = connection.connectionId
return data
}
/**
* Get all existing language interpretation channels
*/
function getChannels(connections) {
let channels = []
Object.keys(connections || {}).forEach(key => {
let conn = connections[key]
if (
conn.language
&& !channels.includes(conn.language)
) {
channels.push(conn.language)
}
})
return channels
}
}
export { Meet, Roles }
diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss
index 34d51ad8..40504e22 100644
--- a/src/resources/themes/meet.scss
+++ b/src/resources/themes/meet.scss
@@ -1,518 +1,512 @@
.meet-nickname {
padding: 0;
line-height: 2em;
border-radius: 1em;
max-width: 100%;
white-space: nowrap;
display: inline-flex;
.icon {
display: inline-block;
min-width: 2em;
}
.content {
order: 1;
height: 2em;
outline: none;
overflow: hidden;
text-overflow: ellipsis;
&:not(:empty) {
margin-left: 0.5em;
padding-right: 0.5em;
& + .icon {
margin-right: -0.75em;
}
}
}
.self & {
.content {
&:focus {
min-width: 0.5em;
}
}
}
& + .dropdown-menu {
.permissions > label {
margin: 0;
padding-left: 3.75rem;
}
}
}
.meet-video {
position: relative;
background: $menu-bg-color;
// Use flexbox for centering .watermark
display: flex;
align-items: center;
justify-content: center;
video {
// To make object-fit:cover working we have to set the height in pixels
// on the wrapper element. This is what javascript method will do.
object-fit: cover;
width: 100%;
height: 100%;
background: #000;
& + .watermark {
display: none;
}
}
&.screen video {
// For shared screen videos we use the original aspect ratio
object-fit: scale-down;
background: none;
}
&.fullscreen {
video {
// We don't want the video to be cut in fullscreen
// This will preserve the aspect ratio of the video stream
object-fit: contain;
}
}
.watermark {
color: darken($menu-bg-color, 20%);
width: 50%;
height: 50%;
}
.controls {
position: absolute;
bottom: 0;
right: 0;
margin: 0.5em;
padding: 0 0.05em;
line-height: 2em;
border-radius: 1em;
background: rgba(#000, 0.7);
button {
line-height: 2;
border-radius: 50%;
padding: 0;
width: 2em;
}
}
.status {
position: absolute;
bottom: 0;
left: 0;
margin: 0.5em;
line-height: 2em;
span {
display: inline-block;
color: #fff;
border-radius: 50%;
width: 2em;
text-align: center;
margin-right: 0.25em;
}
}
.dropdown {
position: absolute !important;
top: 0;
left: 0;
right: 0;
}
.meet-nickname {
margin: 0.5em;
max-width: calc(100% - 1em);
border: 0;
&:not(:hover) {
background-color: rgba(#fff, 0.8);
}
}
&:not(.moderated):not(.self) .meet-nickname {
.icon {
display: none;
}
}
}
#meet-component {
flex-grow: 1;
display: flex;
flex-direction: column;
& + .filler {
display: none;
}
}
#app.meet {
height: 100%;
#meet-component {
overflow: hidden;
}
nav.navbar {
display: none;
}
}
#meet-setup {
max-width: 720px;
}
#meet-auth {
margin-top: 2rem;
margin-bottom: 2rem;
flex: 1;
}
#meet-counter {
position: absolute;
left: 0;
padding: 1.1em 0.6em;
color: #808c99;
svg {
font-size: 1.4em;
vertical-align: text-bottom;
}
}
#meet-session-toolbar {
display: flex;
justify-content: center;
}
#meet-session-menu {
background: $menu-bg-color;
border-radius: 0 2em 2em 0;
margin: 0.25em 0;
padding-right: 0.5em;
button {
font-size: 1.3em;
margin: 0.4em;
position: relative;
color: #4f5963;
width: 1.75em;
height: 1.75em;
display: inline-flex;
align-items: center;
justify-content: center;
&.on:not(:disabled) {
background: #4f5963;
color: white;
}
.badge {
font-size: 0.5em;
position: absolute;
right: -0.5em;
top: -0.5em;
&:empty {
display: none;
}
}
svg {
width: 1em;
}
}
}
#meet-session-logo {
background: #e9e7e2;
border-radius: 2em 0 0 2em;
margin: 0.25em 0;
display: flex;
flex-direction: column;
justify-content: center;
img {
height: 1.25em;
padding: 0 1em 0 1.5em;
}
}
#meet-session-layout {
flex: 1;
overflow: hidden;
}
#meet-publishers {
height: 100%;
position: relative;
}
#meet-subscribers {
padding: 0.15em;
overflow-y: auto;
.meet-subscriber {
margin: 0.15em;
max-width: calc(25% - 0.4em);
}
// Language interpreters will be displayed as subscribers, but will have still
// the video element that we will hide
video {
display: none;
}
}
#meet-session {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
& > div {
display: flex;
flex-wrap: wrap;
width: 100%;
&:empty {
display: none;
}
}
#meet-publishers:empty {
& + #meet-subscribers {
justify-content: center;
align-content: center;
flex: 1;
}
}
-
- #meet-publishers:not(:empty) {
- & + #meet-subscribers {
- max-height: 30%;
- }
- }
}
#meet-chat {
width: 0;
display: none;
flex-direction: column;
background: $menu-bg-color;
padding-top: 0.25em;
&.open {
width: 30%;
display: flex !important;
.mobile & {
width: 100%;
z-index: 1;
}
}
.chat {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
}
.message {
margin: 0 0.5em 0.5em 0.5em;
padding: 0.25em 0.5em;
border-radius: 1em;
background: #fff;
border: 1px solid #e9e7e2;
overflow-wrap: break-word;
&.self {
background: lighten($main-color, 30%);
}
&:last-child {
margin-bottom: 0;
}
}
.nickname {
font-size: 80%;
color: $secondary;
text-align: right;
}
// TODO: mobile mode
}
#meet-queue {
display: none;
width: 150px;
.head {
text-align: center;
font-size: 1.75em;
background: $menu-bg-color;
}
.dropdown {
margin: 0.2em;
display: flex;
position: relative;
transition: top 10s ease;
top: 15em;
.meet-nickname {
width: 100%;
}
&.widdle {
top: 0;
animation-name: wiggle;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: 8;
}
}
}
@keyframes wiggle {
0% { transform: rotate(0deg); }
25% { transform: rotate(10deg); }
50% { transform: rotate(0deg); }
75% { transform: rotate(-10deg); }
100% { transform: rotate(0deg); }
}
.media-setup-form {
.input-group svg {
width: 1em;
}
}
.media-setup-preview {
display: flex;
position: relative;
video {
width: 100%;
background: #000;
}
.volume {
height: 50%;
position: absolute;
bottom: 1em;
right: 2em;
width: 0.5em;
background: rgba(0, 0, 0, 0.5);
.bar {
width: 100%;
position: absolute;
bottom: 0;
}
#media-setup-dialog & {
right: 1em;
}
}
}
.toast.join-request {
.toast-header {
color: #eee;
}
.toast-body {
display: flex;
}
.picture {
margin-right: 1em;
img {
width: 64px;
height: 64px;
border: 1px solid #555;
border-radius: 50%;
object-fit: cover;
}
}
.content {
flex: 1;
}
}
@include media-breakpoint-down(xs) {
#meet-session-menu {
white-space: nowrap;
margin: 0;
margin-top: 1.25em;
border-radius: 0;
padding: 0;
background: none;
button {
margin: 0.25em;
}
// .link-screen,
.link-fullscreen {
display: none;
}
}
#meet-session-logo {
background: none;
position: absolute;
left: 0;
img {
height: 0.8em;
padding: 0 0.25em;
}
}
#meet-counter {
text-align: right;
right: 0;
padding: 0 0.25em;
font-size: 0.9em;
background: $menu-bg-color;
svg {
font-size: 1.1em;
vertical-align: -0.125em;
}
}
#meet-setup {
.card-title {
text-align: center;
}
}
#meet-queue {
width: 100px;
.dropdown {
animation: none !important;
transition: none;
top: 0;
}
}
.media-setup-preview {
// Fix video element size in Safari/iOS
display: block;
}
}
diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php
index ce91fe89..70a173ae 100644
--- a/src/tests/Feature/Controller/OpenViduTest.php
+++ b/src/tests/Feature/Controller/OpenViduTest.php
@@ -1,763 +1,780 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\OpenViduController;
use App\OpenVidu\Connection;
use App\OpenVidu\Room;
use Tests\TestCase;
class OpenViduTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->clearMeetEntitlements();
$room = Room::where('name', 'john')->first();
- $room->setSettings(['password' => null, 'locked' => null]);
+ $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
}
public function tearDown(): void
{
$this->clearMeetEntitlements();
$room = Room::where('name', 'john')->first();
- $room->setSettings(['password' => null, 'locked' => null]);
+ $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
parent::tearDown();
}
/**
* Test listing user rooms
*
* @group openvidu
*/
public function testIndex(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
Room::where('user_id', $jack->id)->delete();
// Unauth access not allowed
$response = $this->get("api/v4/openvidu/rooms");
$response->assertStatus(401);
// John has one room
$response = $this->actingAs($john)->get("api/v4/openvidu/rooms");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame('john', $json['list'][0]['name']);
// Jack has no room, but it will be auto-created
$response = $this->actingAs($jack)->get("api/v4/openvidu/rooms");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertRegExp('/^[0-9a-z-]{11}$/', $json['list'][0]['name']);
}
/**
* Test joining the room
*
* @group openvidu
*/
public function testJoinRoom(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
$this->assignMeetEntitlement($john);
// Unauth access, no session yet
$response = $this->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(323, $json['code']);
// Non-existing room name
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing");
$response->assertStatus(404);
// TODO: Test accessing an existing room of deleted owner
// Non-owner, no session yet
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(323, $json['code']);
// Room owner, no session yet
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(324, $json['code']);
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$session_id = $room->fresh()->session_id;
$this->assertSame(Room::ROLE_SUBSCRIBER | Room::ROLE_MODERATOR | Room::ROLE_OWNER, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(is_string($session_id) && !empty($session_id));
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$john_token = $json['token'];
// Non-owner, now the session exists, no 'init' argument
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(322, $json['code']);
$this->assertTrue(empty($json['token']));
// Non-owner, now the session exists, with 'init', but no 'canPublish' argument
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_SUBSCRIBER, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue($json['token'] != $john_token);
// Non-owner, now the session exists, with 'init', and with 'role=PUBLISHER'
$post = ['canPublish' => true, 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue($json['token'] != $john_token);
$this->assertEmpty($json['config']['password']);
$this->assertEmpty($json['config']['requires_password']);
// Non-owner, password protected room, password not provided
$room->setSettings(['password' => 'pass']);
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(325, $json['code']);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to join the session. Invalid password.', $json['message']);
$this->assertEmpty($json['config']['password']);
$this->assertTrue($json['config']['requires_password']);
// Non-owner, password protected room, invalid provided
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['password' => 'aa']);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(325, $json['code']);
// Non-owner, password protected room, valid password provided
// TODO: Test without init=1
$post = ['password' => 'pass', 'init' => 'init'];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($session_id, $json['session']);
// Make sure the room owner can access the password protected room w/o password
// TODO: Test without init=1
$post = ['init' => 'init'];
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
+
+ // Test 'nomedia' room option
+ $room->setSettings(['nomedia' => 'true', 'password' => null]);
+
+ $post = ['init' => 'init', 'canPublish' => true];
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame(Room::ROLE_PUBLISHER & $json['role'], Room::ROLE_PUBLISHER);
+
+ $post = ['init' => 'init', 'canPublish' => true];
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame(Room::ROLE_PUBLISHER & $json['role'], 0);
}
/**
* Test locked room and join requests
*
* @group openvidu
*/
public function testJoinRequests(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
$room->setSettings(['password' => null, 'locked' => 'true']);
$this->assignMeetEntitlement($john);
// Create the session (also makes sure the owner can access a locked room)
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
// Non-owner, locked room, invalid/missing input
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(326, $json['code']);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to join the session. Room locked.', $json['message']);
$this->assertTrue($json['config']['locked']);
// Non-owner, locked room, invalid requestId
$post = ['nickname' => 'name', 'requestId' => '-----', 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(326, $json['code']);
// Non-owner, locked room, invalid requestId
$post = ['nickname' => 'name', 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(326, $json['code']);
// Non-owner, locked room, valid input
$reqId = '12345678';
$post = ['nickname' => 'name', 'requestId' => $reqId, 'picture' => 'data:image/png;base64,01234'];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(327, $json['code']);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to join the session. Room locked.', $json['message']);
$this->assertTrue($json['config']['locked']);
// TODO: How do we assert that a signal has been sent to the owner?
// Test denying a request
// Unknown room
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/deny");
$response->assertStatus(404);
// Unknown request Id
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/deny");
$response->assertStatus(500);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to deny the join request.', $json['message']);
// Non-owner access forbidden
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny");
$response->assertStatus(403);
// Valid request
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
// Non-owner, locked room, join request denied
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(327, $json['code']);
// Test accepting a request
// Unknown room
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/accept");
$response->assertStatus(404);
// Unknown request Id
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/accept");
$response->assertStatus(500);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to accept the join request.', $json['message']);
// Non-owner access forbidden
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept");
$response->assertStatus(403);
// Valid request
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
// Non-owner, locked room, join request accepted
$post['init'] = 1;
$post['canPublish'] = true;
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
// TODO: Test a scenario where both password and lock are enabled
// TODO: Test accepting/denying as a non-owner moderator
}
/**
* Test joining the room
*
* @group openvidu
* @depends testJoinRoom
*/
public function testJoinRoomGuest(): void
{
$this->assignMeetEntitlement('john@kolab.org');
// There's no asy way to logout the user in the same test after
// using actingAs(). That's why this is moved to a separate test
$room = Room::where('name', 'john')->first();
// Guest, request with screenShare token
$post = ['canPublish' => true, 'screenShare' => 1, 'init' => 1];
$response = $this->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertSame($room->session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
}
/**
* Test closing the room (session)
*
* @group openvidu
* @depends testJoinRoom
*/
public function testCloseRoom(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
// Unauth access not allowed
$response = $this->post("api/v4/openvidu/rooms/{$room->name}/close", []);
$response->assertStatus(401);
// Non-existing room name
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/close", []);
$response->assertStatus(404);
// Non-owner
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/close", []);
$response->assertStatus(403);
// Room owner
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/close", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertNull($room->fresh()->session_id);
$this->assertSame('success', $json['status']);
$this->assertSame("The session has been closed successfully.", $json['message']);
$this->assertCount(2, $json);
// TODO: Test if the session is removed from the OpenVidu server too
// Test error handling when it's not possible to delete the session on
// the OpenVidu server (use fake session_id)
$room->session_id = 'aaa';
$room->save();
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/close", []);
$response->assertStatus(500);
$json = $response->json();
$this->assertSame('aaa', $room->fresh()->session_id);
$this->assertSame('error', $json['status']);
$this->assertSame("Failed to close the session.", $json['message']);
$this->assertCount(2, $json);
}
/**
* Test creating an extra connection for screen sharing
*
* @group openvidu
*/
public function testCreateConnection(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
$this->assignMeetEntitlement($john);
// First we create the session
$post = ['init' => 1, 'canPublish' => 1];
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$owner_auth_token = $json['authToken'];
// And the other user connection
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$conn_id = $json['connectionId'];
$auth_token = $json['authToken'];
// Non-existing room name
$response = $this->post("api/v4/openvidu/rooms/non-existing/connections", []);
$response->assertStatus(404);
// No connection token provided
$response = $this->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
$response->assertStatus(403);
// Invalid token
$response = $this->actingAs($jack)
->withHeaders([OpenViduController::AUTH_HEADER => '123'])
->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
$response->assertStatus(403);
// Subscriber can't get the screen-sharing connection
// Note: We're acting as Jack because there's no easy way to unset the 'actingAs' user
// throughout the test
$response = $this->actingAs($jack)
->withHeaders([OpenViduController::AUTH_HEADER => $auth_token])
->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
$response->assertStatus(403);
// Publisher can get the connection
$response = $this->actingAs($jack)
->withHeaders([OpenViduController::AUTH_HEADER => $owner_auth_token])
->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue(strpos($json['token'], 'role=PUBLISHER') !== false);
}
/**
* Test dismissing a participant (closing a connection)
*
* @group openvidu
*/
public function testDismissConnection(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
$this->assignMeetEntitlement($john);
// First we create the session
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
// And the other user connection
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$conn_id = $json['connectionId'];
$room->refresh();
$conn_data = $room->getOVConnection($conn_id);
$this->assertSame($conn_id, $conn_data['connectionId']);
// Non-existing room name
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}/dismiss");
$response->assertStatus(404);
// TODO: Test accessing an existing room of deleted owner
// Non-existing connection
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/123/dismiss");
$response->assertStatus(404);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertSame('The connection does not exist.', $json['message']);
// Non-owner access
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
$response->assertStatus(403);
// Expected success
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertNull($room->getOVConnection($conn_id));
// Test acting as a moderator
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$conn_id = $json['connectionId'];
// Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
// throughout the test
$response = $this->actingAs($jack)
->withHeaders([OpenViduController::AUTH_HEADER => $this->getModeratorToken($room)])
->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
$response->assertStatus(200);
}
/**
* Test configuring the room (session)
*
* @group openvidu
*/
public function testSetRoomConfig(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
// Unauth access not allowed
$response = $this->post("api/v4/openvidu/rooms/{$room->name}/config", []);
$response->assertStatus(401);
// Non-existing room name
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/config", []);
$response->assertStatus(404);
// TODO: Test a room with a deleted owner
// Non-owner
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/config", []);
$response->assertStatus(403);
// Room owner
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame("Room configuration updated successfully.", $json['message']);
// Set password and room lock
$post = ['password' => 'aaa', 'locked' => 1];
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame("Room configuration updated successfully.", $json['message']);
$room->refresh();
$this->assertSame('aaa', $room->getSetting('password'));
$this->assertSame('true', $room->getSetting('locked'));
// Unset password and room lock
$post = ['password' => '', 'locked' => 0];
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame("Room configuration updated successfully.", $json['message']);
$room->refresh();
$this->assertSame(null, $room->getSetting('password'));
$this->assertSame(null, $room->getSetting('locked'));
// Test invalid option error
$post = ['password' => 'eee', 'unknown' => 0];
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertSame("Invalid room configuration option.", $json['errors']['unknown']);
$room->refresh();
$this->assertSame(null, $room->getSetting('password'));
}
/**
* Test updating a participant (connection)
*
* @group openvidu
*/
public function testUpdateConnection(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
$this->assignMeetEntitlement($john);
// First we create the session
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$owner_conn_id = $json['connectionId'];
// And the other user connection
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$conn_id = $json['connectionId'];
$auth_token = $json['authToken'];
$room->refresh();
$conn_data = $room->getOVConnection($conn_id);
$this->assertSame($conn_id, $conn_data['connectionId']);
// Non-existing room name
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}", []);
$response->assertStatus(404);
// Non-existing connection
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/123", []);
$response->assertStatus(404);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertSame('The connection does not exist.', $json['message']);
// Non-owner access (empty post)
$response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", []);
$response->assertStatus(200);
// Non-owner access (role update)
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR];
$response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(403);
// Expected success
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR];
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame($post['role'], Connection::find($conn_id)->role);
// Access as moderator
// Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
// throughout the test
$token = $this->getModeratorToken($room);
$post = ['role' => Room::ROLE_PUBLISHER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame($post['role'], Connection::find($conn_id)->role);
// Assert that it's not possible to add/remove the 'owner' role
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(403);
$post = ['role' => Room::ROLE_PUBLISHER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post);
$response->assertStatus(403);
// Assert that removing a 'moderator' role from the owner is not possible
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post);
$response->assertStatus(200);
$this->assertSame($post['role'] | Room::ROLE_MODERATOR, Connection::find($owner_conn_id)->role);
// Assert that non-moderator token does not allow access
$post = ['role' => Room::ROLE_SUBSCRIBER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $auth_token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(403);
// TODO: Test updating 'language' and 'hand' properties
}
/**
* Create a moderator connection to the room session.
*
* @param \App\OpenVidu\Room $room The room
*
* @return string The connection authentication token
*/
private function getModeratorToken(Room $room): string
{
$result = $room->getSessionToken(Room::ROLE_MODERATOR);
return $result['authToken'];
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Feb 5, 5:08 AM (7 h, 15 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
427689
Default Alt Text
(121 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment