diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php
index aaedece2..e4995162 100644
--- a/src/app/Http/Controllers/API/V4/OpenViduController.php
+++ b/src/app/Http/Controllers/API/V4/OpenViduController.php
@@ -1,531 +1,564 @@
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',
'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(request()->input('canPublish'));
$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;
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'));
}
- // Only the moderator can do it
- if (!$this->isModerator($connection->room)) {
- return $this->errorResponse(403);
- }
-
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 '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]);
+ }
+
$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/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php
index 57a86c4d..ae52d229 100644
--- a/src/app/Observers/OpenVidu/ConnectionObserver.php
+++ b/src/app/Observers/OpenVidu/ConnectionObserver.php
@@ -1,32 +1,64 @@
role != $connection->getOriginal('role')) {
- $params = [
- 'connectionId' => $connection->id,
- 'role' => $connection->role
- ];
+ $params = [];
- // Send the signal to all participants
- $connection->room->signal('connectionUpdate', $params);
+ // Role change
+ if ($connection->role != $connection->getOriginal('role')) {
+ $params['role'] = $connection->role;
// TODO: When demoting publisher to subscriber maybe we should
// destroy all streams using REST API. For now we trust the
// participant browser to do this.
}
+
+ // Rised hand state change
+ $newState = $connection->metadata['hand'] ?? null;
+ $oldState = $this->getOriginal($connection, 'metadata')['hand'] ?? null;
+
+ if ($newState !== $oldState) {
+ $params['hand'] = !empty($newState);
+ }
+
+ // Send the signal to all participants
+ if (!empty($params)) {
+ $params['connectionId'] = $connection->id;
+ $connection->room->signal('connectionUpdate', $params);
+ }
+ }
+
+ /**
+ * A wrapper to getOriginal() on an object
+ *
+ * @param \App\OpenVidu\Connection $connection The connection.
+ * @param string $property The property name
+ *
+ * @return mixed
+ */
+ private function getOriginal($connection, $property)
+ {
+ $original = $connection->getOriginal($property);
+
+ // The original value for a property is in a format stored in database
+ // I.e. for 'metadata' it is a JSON string instead of an array
+ if ($property == 'metadata') {
+ $original = json_decode($original, true);
+ }
+
+ return $original;
}
}
diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php
index 6276a4e1..b6e8b4d6 100644
--- a/src/app/OpenVidu/Room.php
+++ b/src/app/OpenVidu/Room.php
@@ -1,412 +1,418 @@
false, // No exceptions from Guzzle
'base_uri' => \config('openvidu.api_url'),
'verify' => \config('openvidu.api_verify_tls'),
'auth' => [
\config('openvidu.api_username'),
\config('openvidu.api_password')
]
]
);
}
return self::$client;
}
/**
* Destroy a OpenVidu connection
*
* @param string $conn Connection identifier
*
* @return bool True on success, False otherwise
* @throws \Exception if session does not exist
*/
public function closeOVConnection($conn): bool
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn);
$response = $this->client()->request('DELETE', $url);
return $response->getStatusCode() == 204;
}
/**
* Fetch a OpenVidu connection information.
*
* @param string $conn Connection identifier
*
* @return ?array Connection data on success, Null otherwise
* @throws \Exception if session does not exist
*/
public function getOVConnection($conn): ?array
{
// Note: getOVConnection() not getConnection() because Eloquent\Model::getConnection() exists
// TODO: Maybe use some other name? getParticipant?
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn);
$response = $this->client()->request('GET', $url);
if ($response->getStatusCode() == 200) {
return json_decode($response->getBody(), true);
}
return null;
}
/**
* Create a OpenVidu session
*
* @return array|null Session data on success, NULL otherwise
*/
public function createSession(): ?array
{
$response = $this->client()->request(
'POST',
"sessions",
[
'json' => [
'mediaMode' => 'ROUTED',
'recordingMode' => 'MANUAL'
]
]
);
if ($response->getStatusCode() !== 200) {
$this->session_id = null;
$this->save();
return null;
}
$session = json_decode($response->getBody(), true);
$this->session_id = $session['id'];
$this->save();
return $session;
}
/**
* Delete a OpenVidu session
*
* @return bool
*/
public function deleteSession(): bool
{
if (!$this->session_id) {
return true;
}
$response = $this->client()->request(
'DELETE',
"sessions/" . $this->session_id,
);
if ($response->getStatusCode() == 204) {
$this->session_id = null;
$this->save();
return true;
}
return false;
}
/**
* Returns metadata for every connection in a session.
*
* @return array Connections metadata, indexed by connection identifier
* @throws \Exception if session does not exist
*/
public function getSessionConnections(): array
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
return Connection::where('session_id', $this->session_id)
// Ignore screen sharing connection for now
->whereRaw("(role & " . self::ROLE_SCREEN . ") = 0")
->get()
->keyBy('id')
->map(function ($item) {
- // For now we need only 'role' property, it might change in the future.
- // Make sure to not return all metadata here as it might contain sensitive data.
- return ['role' => $item->role];
+ // Warning: Make sure to not return all metadata here as it might contain sensitive data.
+ return [
+ 'role' => $item->role,
+ 'hand' => $item->metadata['hand'] ?? 0,
+ ];
+ })
+ // Sort by order in the queue, so UI can re-build the existing queue in order
+ ->sort(function ($a, $b) {
+ return $a['hand'] <=> $b['hand'];
})
->all();
}
/**
* Create a OpenVidu session (connection) token
*
* @param int $role User role (see self::ROLE_* constants)
*
* @return array|null Token data on success, NULL otherwise
* @throws \Exception if session does not exist
*/
public function getSessionToken($role = self::ROLE_SUBSCRIBER): ?array
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
// FIXME: Looks like passing the role in 'data' param is the only way
// to make it visible for everyone in a room. So, for example we can
// handle/style subscribers/publishers/moderators differently on the
// client-side. Is this a security issue?
$data = ['role' => $role];
$url = 'sessions/' . $this->session_id . '/connection';
$post = [
'json' => [
'role' => self::OV_ROLE_PUBLISHER,
'data' => json_encode($data)
]
];
$response = $this->client()->request('POST', $url, $post);
if ($response->getStatusCode() == 200) {
$json = json_decode($response->getBody(), true);
$authToken = base64_encode($json['id'] . ':' . \random_bytes(16));
// Extract the 'token' part of the token, it will be used to authenticate the connection.
// It will be needed in next iterations e.g. to authenticate moderators that aren't
// Kolab4 users (or are just not logged in to Kolab4).
// FIXME: we could as well generate our own token for auth purposes
parse_str(parse_url($json['token'], PHP_URL_QUERY), $url);
// Create the connection reference in our database
$conn = new Connection();
$conn->id = $json['id'];
$conn->session_id = $this->session_id;
$conn->room_id = $this->id;
$conn->role = $role;
$conn->metadata = ['token' => $url['token'], 'authToken' => $authToken];
$conn->save();
return [
'session' => $this->session_id,
'token' => $json['token'],
'authToken' => $authToken,
'connectionId' => $json['id'],
'role' => $role,
];
}
return null;
}
/**
* Check if the room has an active session
*
* @return bool True when the session exists, False otherwise
*/
public function hasSession(): bool
{
if (!$this->session_id) {
return false;
}
$response = $this->client()->request('GET', "sessions/{$this->session_id}");
return $response->getStatusCode() == 200;
}
/**
* The room owner.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function owner()
{
return $this->belongsTo('\App\User', 'user_id', 'id');
}
/**
* Accept the join request.
*
* @param string $id Request identifier
*
* @return bool True on success, False on failure
*/
public function requestAccept(string $id): bool
{
$request = Cache::get($this->session_id . '-' . $id);
if ($request) {
$request['status'] = self::REQUEST_ACCEPTED;
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
return false;
}
/**
* Deny the join request.
*
* @param string $id Request identifier
*
* @return bool True on success, False on failure
*/
public function requestDeny(string $id): bool
{
$request = Cache::get($this->session_id . '-' . $id);
if ($request) {
$request['status'] = self::REQUEST_DENIED;
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
return false;
}
/**
* Get the join request data.
*
* @param string $id Request identifier
*
* @return array|null Request data (e.g. nickname, status, picture?)
*/
public function requestGet(string $id): ?array
{
return Cache::get($this->session_id . '-' . $id);
}
/**
* Save the join request.
*
* @param string $id Request identifier
* @param array $request Request data
*
* @return bool True on success, False on failure
*/
public function requestSave(string $id, array $request): bool
{
// We don't really need the picture in the cache
// As we use this cache for the request status only
unset($request['picture']);
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
/**
* Any (additional) properties of this room.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function settings()
{
return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id');
}
/**
* Send a OpenVidu signal to the session participants (connections)
*
* @param string $name Signal name (type)
* @param array $data Signal data array
* @param null|int|string[] $target List of target connections, Null for all connections.
* It can be also a participant role.
*
* @return bool True on success, False on failure
* @throws \Exception if session does not exist
*/
public function signal(string $name, array $data = [], $target = null): bool
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$post = [
'session' => $this->session_id,
'type' => $name,
'data' => $data ? json_encode($data) : '',
];
// Get connection IDs by participant role
if (is_int($target)) {
$connections = Connection::where('session_id', $this->session_id)
->whereRaw("(role & $target)")
->pluck('id')
->all();
if (empty($connections)) {
return false;
}
$target = $connections;
}
if (!empty($target)) {
$post['to'] = $target;
}
$response = $this->client()->request('POST', 'signal', ['json' => $post]);
return $response->getStatusCode() == 200;
}
}
diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
index 1bbc466f..5ba45315 100644
--- a/src/resources/js/meet/app.js
+++ b/src/resources/js/meet/app.js
@@ -1,1415 +1,1472 @@
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 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.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),
* chatElement - DOM element for the chat widget,
* 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) {
resize();
volumeMeterStop()
data.params = {
nickname: data.nickname, // user nickname
// avatar: undefined // avatar image
}
// Create a container for subscribers
if (!subscribersContainer) {
subscribersContainer = $('
').appendTo(container).get(0)
}
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])
- delete 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);
subscriber.on('videoElementCreated', event => {
$(event.element).prop({
tabindex: -1
})
resize()
})
metadata.audioActive = event.stream.audioActive
metadata.videoActive = event.stream.videoActive
// 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
}
if (metadata) {
metadata[event.changedProperty] = event.newValue
participantUpdate(metadata.element, metadata)
}
})
// Handle session disconnection events
session.on('sessionDisconnected', event => {
if (data.onDestroy) {
data.onDestroy(event)
}
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()
}
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)
+ }
+ })
})
.catch(error => {
console.error('There was an error connecting to the session: ', error.message);
if (data.onError) {
data.onError(error)
}
})
// Prepare the chat
setupChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom() {
if (publisher) {
volumeMeterStop()
// FIXME: We have to unpublish streams only if there's no session yet
if (!session && audioActive) {
publisher.publishAudio(false)
}
if (!session && videoActive) {
publisher.publishVideo(false)
}
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('')
.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) {
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 = $('').text(data.message).text() // make the message secure
// Format the message, convert emails and urls to links
message = anchorme({
input: message,
options: {
attributes: {
target: "_blank"
},
// any link above 20 characters will be truncated
// to 20 characters and ellipses at the end
truncate: 20,
// characters will be taken out of the middle
middleTruncation: true
}
// TODO: anchorme is extensible, we could support
// github/phabricator's markup e.g. backticks for code samples
})
message = message.replace(/\r?\n/, ' ')
// Display the message
let isSelf = data.id == session.connectionId
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('
').html(message)
message.find('a').attr('rel', 'noreferrer')
if (box.length && box.data('id') == data.id) {
// A message from the same user as the last message, no new box needed
message.appendTo(box)
} else {
box = $('
').data('id', data.id)
.append($('
').text(data.nickname || ''))
.append(message)
.appendTo(chat)
if (isSelf) {
box.addClass('self')
}
}
// Count unread messages
if (!$(sessionData.chatElement).is('.open')) {
if (!isSelf) {
chatCount++
}
} else {
chatCount = 0
}
$(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '')
// 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
})
}
}
/**
* 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 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
// Inform the vue component, so it can update some UI controls
let update = () => {
if (sessionData.onSessionDataUpdate) {
sessionData.onSessionDataUpdate(data)
}
}
// 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
Object.keys(data).forEach(key => { sessionData[key] = data[key] })
// update the participant element
sessionData.element = participantUpdate(sessionData.element, sessionData)
// promoted/demoted to/from a moderator
if ('role' in data) {
if ((!isModerator && roleModerator) || (isModerator && !roleModerator)) {
// Update all participants, to enable/disable the popup menu
Object.keys(connections).forEach(key => {
const conn = connections[key]
participantUpdate(conn.element, conn)
})
}
}
+ // Inform the vue component, so it can update some UI controls
+ update()
+
// promoted to a publisher
if ('role' in data && !isPublisher && rolePublisher) {
publisher.createVideoElement(sessionData.element, 'PREPEND')
session.publish(publisher).then(() => {
data.audioActive = publisher.stream.audioActive
data.videoActive = publisher.stream.videoActive
update()
})
// 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()
}
- } else {
- // Inform the vue component, so it can update some UI controls
- update()
}
} 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 nickname in chat
+ * 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
*
* @return The element
*/
function participantCreate(params) {
let element
params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId
if (params.role & Roles.PUBLISHER || params.role & Roles.SCREEN) {
element = publisherCreate(params)
} else {
element = subscriberCreate(params)
}
setTimeout(resize, 50);
return element
}
/**
* Create a