Fix LiveKit connect flow and polish meeting room media UI.
Deploy Ladill Meet / deploy (push) Successful in 1m2s
Deploy Ladill Meet / deploy (push) Successful in 1m2s
Use the official LiveKit PHP SDK for tokens, harden client connect/publish timing, keep the Room outside Alpine reactivity, and replace emoji toolbar controls with synced mic/camera toggles and avatar tiles for audio-only participants. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Services\Meet\Media;
|
namespace App\Services\Meet\Media;
|
||||||
|
|
||||||
|
use Agence104\LiveKit\AccessToken;
|
||||||
|
use Agence104\LiveKit\AccessTokenOptions;
|
||||||
|
use Agence104\LiveKit\VideoGrant;
|
||||||
|
|
||||||
class LiveKitProvider implements MediaProviderInterface
|
class LiveKitProvider implements MediaProviderInterface
|
||||||
{
|
{
|
||||||
public function createRoom(string $roomName, array $options = []): void
|
public function createRoom(string $roomName, array $options = []): void
|
||||||
@@ -23,25 +27,26 @@ class LiveKitProvider implements MediaProviderInterface
|
|||||||
$apiKey = (string) config('meet.media.livekit.api_key');
|
$apiKey = (string) config('meet.media.livekit.api_key');
|
||||||
$apiSecret = (string) config('meet.media.livekit.api_secret');
|
$apiSecret = (string) config('meet.media.livekit.api_secret');
|
||||||
|
|
||||||
$now = time();
|
$tokenOptions = (new AccessTokenOptions())
|
||||||
$payload = [
|
->setIdentity($identity)
|
||||||
'iss' => $apiKey,
|
->setName($displayName)
|
||||||
'sub' => $identity,
|
->setTtl(3600);
|
||||||
'iat' => $now,
|
|
||||||
'nbf' => $now,
|
|
||||||
'exp' => $now + 3600,
|
|
||||||
'name' => $displayName,
|
|
||||||
'video' => [
|
|
||||||
'roomJoin' => true,
|
|
||||||
'room' => $roomName,
|
|
||||||
'canPublish' => $grants['can_publish'] ?? true,
|
|
||||||
'canSubscribe' => $grants['can_subscribe'] ?? true,
|
|
||||||
'canPublishData' => $grants['can_publish_data'] ?? true,
|
|
||||||
'roomAdmin' => $grants['room_admin'] ?? false,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this->encodeJwt($payload, $apiSecret);
|
$videoGrant = (new VideoGrant())
|
||||||
|
->setRoomJoin()
|
||||||
|
->setRoomName($roomName)
|
||||||
|
->setCanPublish($grants['can_publish'] ?? true)
|
||||||
|
->setCanSubscribe($grants['can_subscribe'] ?? true)
|
||||||
|
->setCanPublishData($grants['can_publish_data'] ?? true);
|
||||||
|
|
||||||
|
if ($grants['room_admin'] ?? false) {
|
||||||
|
$videoGrant->setRoomAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new AccessToken($apiKey, $apiSecret))
|
||||||
|
->init($tokenOptions)
|
||||||
|
->setGrant($videoGrant)
|
||||||
|
->toJwt();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
@@ -55,23 +60,4 @@ class LiveKitProvider implements MediaProviderInterface
|
|||||||
{
|
{
|
||||||
return (string) config('meet.media.livekit.url');
|
return (string) config('meet.media.livekit.url');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
*/
|
|
||||||
protected function encodeJwt(array $payload, string $secret): string
|
|
||||||
{
|
|
||||||
$header = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'], JSON_THROW_ON_ERROR));
|
|
||||||
$body = $this->base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR));
|
|
||||||
$signature = $this->base64UrlEncode(
|
|
||||||
hash_hmac('sha256', "{$header}.{$body}", $secret, true)
|
|
||||||
);
|
|
||||||
|
|
||||||
return "{$header}.{$body}.{$signature}";
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function base64UrlEncode(string $data): string
|
|
||||||
{
|
|
||||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
|
"agence104/livekit-server-sdk": "^1.3",
|
||||||
"chillerlan/php-qrcode": "^5.0",
|
"chillerlan/php-qrcode": "^5.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.3",
|
"laravel/sanctum": "^4.3",
|
||||||
|
|||||||
Generated
+337
-202
@@ -4,8 +4,59 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "529339feb28ae432869697327a78cb16",
|
"content-hash": "53f235949e861fffd4527a02185afa6e",
|
||||||
"packages": [
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "agence104/livekit-server-sdk",
|
||||||
|
"version": "1.3.5",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/agence104/livekit-server-sdk-php.git",
|
||||||
|
"reference": "d003a809089b28af5b40c467ead1154aa767ac51"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/agence104/livekit-server-sdk-php/zipball/d003a809089b28af5b40c467ead1154aa767ac51",
|
||||||
|
"reference": "d003a809089b28af5b40c467ead1154aa767ac51",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"firebase/php-jwt": "^7.0.0",
|
||||||
|
"google/protobuf": "^3.23|^4.0",
|
||||||
|
"guzzlehttp/guzzle": "^6.3|^7.0",
|
||||||
|
"guzzlehttp/psr7": "^1.6.1|^2.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"twirp/twirp": "^0.9.1|^0.14.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Livekit\\": "src/proto/Livekit",
|
||||||
|
"GPBMetadata\\": "src/proto/GPBMetadata",
|
||||||
|
"Agence104\\LiveKit\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Agence 10-4",
|
||||||
|
"homepage": "https://www.agence104.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Server-side SDK for LiveKit.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/agence104/livekit-server-sdk-php/issues",
|
||||||
|
"source": "https://github.com/agence104/livekit-server-sdk-php/tree/1.3.5"
|
||||||
|
},
|
||||||
|
"time": "2026-03-11T02:56:04+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
"version": "0.14.8",
|
"version": "0.14.8",
|
||||||
@@ -670,6 +721,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "firebase/php-jwt",
|
||||||
|
"version": "v7.1.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/googleapis/php-jwt.git",
|
||||||
|
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
|
||||||
|
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"guzzlehttp/guzzle": "^7.4",
|
||||||
|
"phpfastcache/phpfastcache": "^9.2",
|
||||||
|
"phpseclib/phpseclib": "~3.0",
|
||||||
|
"phpspec/prophecy-phpunit": "^2.0",
|
||||||
|
"phpunit/phpunit": "^9.5",
|
||||||
|
"psr/cache": "^2.0||^3.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
||||||
|
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
|
||||||
|
"phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Firebase\\JWT\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Neuman Vong",
|
||||||
|
"email": "neuman+pear@twilio.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anant Narayanan",
|
||||||
|
"email": "anant@php.net",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
||||||
|
"homepage": "https://github.com/googleapis/php-jwt",
|
||||||
|
"keywords": [
|
||||||
|
"jwt",
|
||||||
|
"php"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/googleapis/php-jwt/issues",
|
||||||
|
"source": "https://github.com/googleapis/php-jwt/tree/v7.1.0"
|
||||||
|
},
|
||||||
|
"time": "2026-06-11T17:54:14+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "fruitcake/php-cors",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@@ -741,6 +858,50 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-03T09:33:47+00:00"
|
"time": "2025-12-03T09:33:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "google/protobuf",
|
||||||
|
"version": "v4.33.6",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/protocolbuffers/protobuf-php.git",
|
||||||
|
"reference": "84b008c23915ed94536737eae46f41ba3bccfe67"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67",
|
||||||
|
"reference": "84b008c23915ed94536737eae46f41ba3bccfe67",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": ">=10.5.62 <11.0.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-bcmath": "Need to support JSON deserialization"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Google\\Protobuf\\": "src/Google/Protobuf",
|
||||||
|
"GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"description": "proto library for PHP",
|
||||||
|
"homepage": "https://developers.google.com/protocol-buffers/",
|
||||||
|
"keywords": [
|
||||||
|
"proto"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6"
|
||||||
|
},
|
||||||
|
"time": "2026-03-18T17:32:05+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "graham-campbell/result-type",
|
"name": "graham-campbell/result-type",
|
||||||
"version": "v1.1.4",
|
"version": "v1.1.4",
|
||||||
@@ -2762,123 +2923,83 @@
|
|||||||
"time": "2026-02-16T23:10:27+00:00"
|
"time": "2026-02-16T23:10:27+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "paragonie/constant_time_encoding",
|
"name": "php-http/discovery",
|
||||||
"version": "v3.1.3",
|
"version": "1.20.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/paragonie/constant_time_encoding.git",
|
"url": "https://github.com/php-http/discovery.git",
|
||||||
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
|
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
|
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
|
||||||
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
|
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8"
|
"composer-plugin-api": "^1.0|^2.0",
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"nyholm/psr7": "<1.0",
|
||||||
|
"zendframework/zend-diactoros": "*"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"php-http/async-client-implementation": "*",
|
||||||
|
"php-http/client-implementation": "*",
|
||||||
|
"psr/http-client-implementation": "*",
|
||||||
|
"psr/http-factory-implementation": "*",
|
||||||
|
"psr/http-message-implementation": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"infection/infection": "^0",
|
"composer/composer": "^1.0.2|^2.0",
|
||||||
"nikic/php-fuzzer": "^0",
|
"graham-campbell/phpspec-skip-example-extension": "^5.0",
|
||||||
"phpunit/phpunit": "^9|^10|^11",
|
"php-http/httplug": "^1.0 || ^2.0",
|
||||||
"vimeo/psalm": "^4|^5|^6"
|
"php-http/message-factory": "^1.0",
|
||||||
|
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
|
||||||
|
"sebastian/comparator": "^3.0.5 || ^4.0.8",
|
||||||
|
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
|
||||||
|
},
|
||||||
|
"type": "composer-plugin",
|
||||||
|
"extra": {
|
||||||
|
"class": "Http\\Discovery\\Composer\\Plugin",
|
||||||
|
"plugin-optional": true
|
||||||
},
|
},
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"ParagonIE\\ConstantTime\\": "src/"
|
"Http\\Discovery\\": "src/"
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Paragon Initiative Enterprises",
|
|
||||||
"email": "security@paragonie.com",
|
|
||||||
"homepage": "https://paragonie.com",
|
|
||||||
"role": "Maintainer"
|
|
||||||
},
|
},
|
||||||
{
|
"exclude-from-classmap": [
|
||||||
"name": "Steve 'Sc00bz' Thomas",
|
"src/Composer/Plugin.php"
|
||||||
"email": "steve@tobtu.com",
|
]
|
||||||
"homepage": "https://www.tobtu.com",
|
|
||||||
"role": "Original Developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
|
|
||||||
"keywords": [
|
|
||||||
"base16",
|
|
||||||
"base32",
|
|
||||||
"base32_decode",
|
|
||||||
"base32_encode",
|
|
||||||
"base64",
|
|
||||||
"base64_decode",
|
|
||||||
"base64_encode",
|
|
||||||
"bin2hex",
|
|
||||||
"encoding",
|
|
||||||
"hex",
|
|
||||||
"hex2bin",
|
|
||||||
"rfc4648"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"email": "info@paragonie.com",
|
|
||||||
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
|
|
||||||
"source": "https://github.com/paragonie/constant_time_encoding"
|
|
||||||
},
|
},
|
||||||
"time": "2025-09-24T15:06:41+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "paragonie/random_compat",
|
|
||||||
"version": "v9.99.100",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/paragonie/random_compat.git",
|
|
||||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
|
||||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">= 7"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "4.*|5.*",
|
|
||||||
"vimeo/psalm": "^1"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
"license": [
|
"license": [
|
||||||
"MIT"
|
"MIT"
|
||||||
],
|
],
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
"name": "Paragon Initiative Enterprises",
|
"name": "Márk Sági-Kazár",
|
||||||
"email": "security@paragonie.com",
|
"email": "mark.sagikazar@gmail.com"
|
||||||
"homepage": "https://paragonie.com"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
|
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
|
||||||
|
"homepage": "http://php-http.org",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"csprng",
|
"adapter",
|
||||||
"polyfill",
|
"client",
|
||||||
"pseudorandom",
|
"discovery",
|
||||||
"random"
|
"factory",
|
||||||
|
"http",
|
||||||
|
"message",
|
||||||
|
"psr17",
|
||||||
|
"psr7"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"email": "info@paragonie.com",
|
"issues": "https://github.com/php-http/discovery/issues",
|
||||||
"issues": "https://github.com/paragonie/random_compat/issues",
|
"source": "https://github.com/php-http/discovery/tree/1.20.0"
|
||||||
"source": "https://github.com/paragonie/random_compat"
|
|
||||||
},
|
},
|
||||||
"time": "2020-10-15T08:29:30+00:00"
|
"time": "2024-10-02T11:20:13+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoption/phpoption",
|
"name": "phpoption/phpoption",
|
||||||
@@ -2955,116 +3076,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-27T19:41:33+00:00"
|
"time": "2025-12-27T19:41:33+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "phpseclib/phpseclib",
|
|
||||||
"version": "3.0.52",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/phpseclib/phpseclib.git",
|
|
||||||
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
|
|
||||||
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"paragonie/constant_time_encoding": "^1|^2|^3",
|
|
||||||
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
|
|
||||||
"php": ">=5.6.1"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "*"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
|
|
||||||
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
|
|
||||||
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
|
|
||||||
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
|
|
||||||
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"phpseclib/bootstrap.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"phpseclib3\\": "phpseclib/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Jim Wigginton",
|
|
||||||
"email": "terrafrost@php.net",
|
|
||||||
"role": "Lead Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Patrick Monnerat",
|
|
||||||
"email": "pm@datasphere.ch",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Andreas Fischer",
|
|
||||||
"email": "bantu@phpbb.com",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Hans-Jürgen Petrich",
|
|
||||||
"email": "petrich@tronic-media.com",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Graham Campbell",
|
|
||||||
"email": "graham@alt-three.com",
|
|
||||||
"role": "Developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
|
|
||||||
"homepage": "http://phpseclib.sourceforge.net",
|
|
||||||
"keywords": [
|
|
||||||
"BigInteger",
|
|
||||||
"aes",
|
|
||||||
"asn.1",
|
|
||||||
"asn1",
|
|
||||||
"blowfish",
|
|
||||||
"crypto",
|
|
||||||
"cryptography",
|
|
||||||
"encryption",
|
|
||||||
"rsa",
|
|
||||||
"security",
|
|
||||||
"sftp",
|
|
||||||
"signature",
|
|
||||||
"signing",
|
|
||||||
"ssh",
|
|
||||||
"twofish",
|
|
||||||
"x.509",
|
|
||||||
"x509"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/phpseclib/phpseclib/issues",
|
|
||||||
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/terrafrost",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://www.patreon.com/phpseclib",
|
|
||||||
"type": "patreon"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-04-27T07:02:15+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "psr/clock",
|
"name": "psr/clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -3376,6 +3387,62 @@
|
|||||||
},
|
},
|
||||||
"time": "2023-04-04T09:54:51+00:00"
|
"time": "2023-04-04T09:54:51+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/http-server-handler",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/http-server-handler.git",
|
||||||
|
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
|
||||||
|
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.0",
|
||||||
|
"psr/http-message": "^1.0 || ^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\Http\\Server\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interface for HTTP server-side request handler",
|
||||||
|
"keywords": [
|
||||||
|
"handler",
|
||||||
|
"http",
|
||||||
|
"http-interop",
|
||||||
|
"psr",
|
||||||
|
"psr-15",
|
||||||
|
"psr-7",
|
||||||
|
"request",
|
||||||
|
"response",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
|
||||||
|
},
|
||||||
|
"time": "2023-04-10T20:06:20+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/log",
|
"name": "psr/log",
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
@@ -6310,6 +6377,74 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-02T11:56:42+00:00"
|
"time": "2025-12-02T11:56:42+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "twirp/twirp",
|
||||||
|
"version": "v0.14.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/twirphp/twirp.git",
|
||||||
|
"reference": "08a54881275a4a874d717f043b5afc1749d57567"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/twirphp/twirp/zipball/08a54881275a4a874d717f043b5afc1749d57567",
|
||||||
|
"reference": "08a54881275a4a874d717f043b5afc1749d57567",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.3 || ^8.0",
|
||||||
|
"php-http/discovery": "^1.19",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.0",
|
||||||
|
"psr/http-message": "^1.0 || ^2.0",
|
||||||
|
"psr/http-server-handler": "^1.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"google/protobuf": "<3.5"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"google/protobuf": "^3.23",
|
||||||
|
"guzzlehttp/guzzle": "^7.7",
|
||||||
|
"guzzlehttp/psr7": "^2.5",
|
||||||
|
"phpspec/prophecy-phpunit": "^2.0",
|
||||||
|
"phpunit/phpunit": "^9.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "0.11-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Twirp\\": "lib/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP port of Twitch's Twirp RPC framework",
|
||||||
|
"homepage": "https://twirphp.github.io",
|
||||||
|
"keywords": [
|
||||||
|
"framework",
|
||||||
|
"idl",
|
||||||
|
"protobuf",
|
||||||
|
"rpc",
|
||||||
|
"twirp"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/twirphp/twirp/issues",
|
||||||
|
"source": "https://github.com/twirphp/twirp/tree/v0.14.0"
|
||||||
|
},
|
||||||
|
"time": "2025-05-02T10:05:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "vlucas/phpdotenv",
|
"name": "vlucas/phpdotenv",
|
||||||
"version": "v5.6.3",
|
"version": "v5.6.3",
|
||||||
@@ -8851,7 +8986,7 @@
|
|||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.4"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
|
|||||||
+333
-57
@@ -1,11 +1,16 @@
|
|||||||
import Alpine from 'alpinejs';
|
import Alpine from 'alpinejs';
|
||||||
import { Room, RoomEvent, Track } from 'livekit-client';
|
import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client';
|
||||||
|
|
||||||
function meetRoom() {
|
function meetRoom() {
|
||||||
|
// LiveKit Room must stay outside Alpine's reactive proxy — wrapping it breaks
|
||||||
|
// structuredClone inside the SDK during connect and media setup.
|
||||||
|
let room = null;
|
||||||
|
let connectPromise = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
room: null,
|
isMuted: true,
|
||||||
isMuted: false,
|
isVideoOff: true,
|
||||||
isVideoOff: false,
|
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
isRecording: false,
|
isRecording: false,
|
||||||
isLocked: false,
|
isLocked: false,
|
||||||
@@ -31,6 +36,9 @@ function meetRoom() {
|
|||||||
config: {},
|
config: {},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
const el = document.getElementById('meet-config');
|
const el = document.getElementById('meet-config');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
@@ -76,43 +84,302 @@ function meetRoom() {
|
|||||||
this.pollTimer = setInterval(() => this.poll(), 3000);
|
this.pollTimer = setInterval(() => this.poll(), 3000);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
connectionErrorMessage(error) {
|
||||||
|
const name = error?.name || '';
|
||||||
|
const message = error?.message || '';
|
||||||
|
|
||||||
|
if (name === 'NotAllowedError' || message.includes('Permission denied')) {
|
||||||
|
return 'Connected — allow camera and microphone in your browser';
|
||||||
|
}
|
||||||
|
if (name === 'NotFoundError' || message.includes('Requested device not found')) {
|
||||||
|
return 'Connected — no camera or microphone found';
|
||||||
|
}
|
||||||
|
if (message.includes('401') || message.toLowerCase().includes('unauthorized')) {
|
||||||
|
return 'LiveKit rejected the session token';
|
||||||
|
}
|
||||||
|
if (message.includes('engine not connected within timeout')) {
|
||||||
|
return 'In call — camera/mic timed out. Use the mic/camera buttons to retry.';
|
||||||
|
}
|
||||||
|
if (message) {
|
||||||
|
return `Connection failed — ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Connection failed';
|
||||||
|
},
|
||||||
|
|
||||||
async connectLiveKit() {
|
async connectLiveKit() {
|
||||||
|
if (connectPromise) {
|
||||||
|
return connectPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectPromise = this.startLiveKitSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.room = new Room({ adaptiveStream: true, dynacast: true });
|
await connectPromise;
|
||||||
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
} catch (e) {
|
||||||
this.attachTrack(track, participant);
|
connectPromise = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startLiveKitSession() {
|
||||||
|
try {
|
||||||
|
room = new Room({
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacast: true,
|
||||||
|
disconnectOnPageLeave: true,
|
||||||
});
|
});
|
||||||
this.room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
|
||||||
|
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||||
|
this.attachTrack(track, publication, participant);
|
||||||
|
});
|
||||||
|
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||||
track.detach();
|
track.detach();
|
||||||
|
if (track.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
|
||||||
|
this.updateParticipantTile(participant);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.room.on(RoomEvent.ParticipantConnected, () => this.syncParticipants());
|
room.on(RoomEvent.TrackMuted, (publication, participant) => {
|
||||||
this.room.on(RoomEvent.ParticipantDisconnected, () => this.syncParticipants());
|
if (publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
|
||||||
this.room.on(RoomEvent.Disconnected, () => {
|
publication.track?.detach();
|
||||||
|
this.updateParticipantTile(participant);
|
||||||
|
}
|
||||||
|
if (publication.kind === Track.Kind.Audio) {
|
||||||
|
this.updateParticipantTile(participant);
|
||||||
|
}
|
||||||
|
if (participant === room.localParticipant) {
|
||||||
|
this.syncLocalMediaState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
room.on(RoomEvent.TrackUnmuted, (publication, participant) => {
|
||||||
|
if (publication.kind === Track.Kind.Video && publication.track) {
|
||||||
|
this.attachTrack(publication.track, publication, participant);
|
||||||
|
}
|
||||||
|
if (publication.kind === Track.Kind.Audio) {
|
||||||
|
this.updateParticipantTile(participant);
|
||||||
|
}
|
||||||
|
if (participant === room.localParticipant) {
|
||||||
|
this.syncLocalMediaState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
room.on(RoomEvent.LocalTrackPublished, (publication, participant) => {
|
||||||
|
if (publication.track?.kind === Track.Kind.Video) {
|
||||||
|
this.attachTrack(publication.track, publication, participant);
|
||||||
|
}
|
||||||
|
this.updateParticipantTile(participant);
|
||||||
|
this.syncLocalMediaState();
|
||||||
|
});
|
||||||
|
room.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => {
|
||||||
|
if (publication.kind === Track.Kind.Video) {
|
||||||
|
this.updateParticipantTile(participant);
|
||||||
|
}
|
||||||
|
this.syncLocalMediaState();
|
||||||
|
});
|
||||||
|
room.on(RoomEvent.ParticipantConnected, () => this.syncParticipants());
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, () => this.syncParticipants());
|
||||||
|
room.on(RoomEvent.Disconnected, () => {
|
||||||
this.connectionStatus = 'Disconnected';
|
this.connectionStatus = 'Disconnected';
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.room.connect(this.config.url, this.config.token);
|
await room.prepareConnection(this.config.url, this.config.token);
|
||||||
|
|
||||||
|
await room.connect(this.config.url, this.config.token, {
|
||||||
|
autoSubscribe: true,
|
||||||
|
peerConnectionTimeout: 20_000,
|
||||||
|
websocketTimeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.waitForRoomConnected(room);
|
||||||
|
|
||||||
|
this.connectionStatus = 'Connected';
|
||||||
|
this.syncParticipants();
|
||||||
|
|
||||||
if (this.canPublish) {
|
if (this.canPublish) {
|
||||||
await this.room.localParticipant.enableCameraAndMicrophone();
|
await this.enableLocalMedia(room);
|
||||||
this.isMuted = false;
|
|
||||||
this.isVideoOff = false;
|
|
||||||
} else {
|
} else {
|
||||||
this.connectionStatus = 'View-only audience mode';
|
this.connectionStatus = 'View-only audience mode';
|
||||||
this.isMuted = true;
|
this.isMuted = true;
|
||||||
this.isVideoOff = true;
|
this.isVideoOff = true;
|
||||||
}
|
}
|
||||||
this.connectionStatus = 'Connected';
|
|
||||||
this.syncParticipants();
|
|
||||||
this.setupCaptionPipeline();
|
this.setupCaptionPipeline();
|
||||||
this.room.localParticipant.videoTrackPublications.forEach((pub) => {
|
|
||||||
if (pub.track) this.attachTrack(pub.track, this.room.localParticipant);
|
this.updateParticipantTile(room.localParticipant);
|
||||||
|
room.localParticipant.videoTrackPublications.forEach((pub) => {
|
||||||
|
if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
|
||||||
|
this.attachTrack(pub.track, pub, room.localParticipant);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
this.connectionStatus = 'Connection failed';
|
this.connectionStatus = this.connectionErrorMessage(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
waitForRoomConnected(activeRoom) {
|
||||||
|
if (activeRoom.state === ConnectionState.Connected) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
activeRoom.off(RoomEvent.Connected, onConnected);
|
||||||
|
reject(new Error('Timed out waiting for LiveKit room connection'));
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
const onConnected = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
activeRoom.once(RoomEvent.Connected, onConnected);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async enableLocalMedia(activeRoom, attempts = 3) {
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await activeRoom.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
await activeRoom.localParticipant.setCameraEnabled(false);
|
||||||
|
this.syncLocalMediaState();
|
||||||
|
this.updateParticipantTile(activeRoom.localParticipant);
|
||||||
|
this.connectionStatus = 'Connected';
|
||||||
|
return;
|
||||||
|
} catch (mediaError) {
|
||||||
|
console.error(mediaError);
|
||||||
|
const retryable = mediaError?.message?.includes('engine not connected within timeout');
|
||||||
|
|
||||||
|
if (!retryable || attempt === attempts) {
|
||||||
|
this.syncLocalMediaState();
|
||||||
|
this.updateParticipantTile(activeRoom.localParticipant);
|
||||||
|
this.connectionStatus = this.connectionErrorMessage(mediaError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncLocalMediaState() {
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const local = room.localParticipant;
|
||||||
|
this.isMuted = !local.isMicrophoneEnabled;
|
||||||
|
this.isVideoOff = !local.isCameraEnabled;
|
||||||
|
},
|
||||||
|
|
||||||
|
participantInitials(name) {
|
||||||
|
const parts = (name || '?').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return '?';
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
avatarHue(name) {
|
||||||
|
let hash = 0;
|
||||||
|
const value = name || 'guest';
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
hash = value.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.abs(hash) % 360;
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrCreateParticipantTile(participant) {
|
||||||
|
const grid = document.getElementById('video-grid');
|
||||||
|
if (!grid) return null;
|
||||||
|
|
||||||
|
const identity = participant.identity;
|
||||||
|
let tile = grid.querySelector(`[data-participant-tile="${identity}"]`);
|
||||||
|
|
||||||
|
if (!tile) {
|
||||||
|
tile = document.createElement('div');
|
||||||
|
tile.dataset.participantTile = identity;
|
||||||
|
tile.className = 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900 ring-1 ring-white/10';
|
||||||
|
tile.innerHTML = `
|
||||||
|
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div>
|
||||||
|
<div data-tile-avatar class="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
|
||||||
|
<div data-tile-avatar-circle class="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-semibold text-white shadow-lg ring-2 ring-white/10"></div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-2 pt-8">
|
||||||
|
<span data-tile-name class="truncate text-xs font-medium text-white"></span>
|
||||||
|
<div class="flex shrink-0 items-center gap-1.5">
|
||||||
|
<span data-tile-mic class="hidden rounded-full bg-black/50 p-1 text-red-400" title="Muted">
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-tile-cam class="hidden rounded-full bg-black/50 p-1 text-slate-300" title="Camera off">
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
grid.appendChild(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = participant.name || participant.identity;
|
||||||
|
const hue = this.avatarHue(displayName);
|
||||||
|
const circle = tile.querySelector('[data-tile-avatar-circle]');
|
||||||
|
circle.style.background = `linear-gradient(135deg, hsl(${hue} 45% 42%), hsl(${(hue + 40) % 360} 50% 28%))`;
|
||||||
|
circle.textContent = this.participantInitials(displayName);
|
||||||
|
tile.querySelector('[data-tile-name]').textContent = displayName;
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
},
|
||||||
|
|
||||||
|
isParticipantVideoActive(participant) {
|
||||||
|
if (!participant.isCameraEnabled) return false;
|
||||||
|
|
||||||
|
const publication = Array.from(participant.videoTrackPublications.values())
|
||||||
|
.find((pub) => pub.source !== Track.Source.ScreenShare && pub.track && !pub.isMuted);
|
||||||
|
|
||||||
|
return Boolean(publication?.track);
|
||||||
|
},
|
||||||
|
|
||||||
|
isParticipantMuted(participant) {
|
||||||
|
return !participant.isMicrophoneEnabled;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateParticipantTile(participant) {
|
||||||
|
const tile = this.getOrCreateParticipantTile(participant);
|
||||||
|
if (!tile) return;
|
||||||
|
|
||||||
|
const stage = tile.querySelector('[data-tile-stage]');
|
||||||
|
const avatar = tile.querySelector('[data-tile-avatar]');
|
||||||
|
const micBadge = tile.querySelector('[data-tile-mic]');
|
||||||
|
const camBadge = tile.querySelector('[data-tile-cam]');
|
||||||
|
const hasVideo = this.isParticipantVideoActive(participant);
|
||||||
|
|
||||||
|
micBadge.classList.toggle('hidden', !this.isParticipantMuted(participant));
|
||||||
|
camBadge.classList.toggle('hidden', hasVideo);
|
||||||
|
|
||||||
|
if (hasVideo) {
|
||||||
|
avatar.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
avatar.classList.remove('hidden');
|
||||||
|
stage.innerHTML = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
attachVideoToTile(participant, track) {
|
||||||
|
const tile = this.getOrCreateParticipantTile(participant);
|
||||||
|
if (!tile) return;
|
||||||
|
|
||||||
|
const stage = tile.querySelector('[data-tile-stage]');
|
||||||
|
const avatar = tile.querySelector('[data-tile-avatar]');
|
||||||
|
const camBadge = tile.querySelector('[data-tile-cam]');
|
||||||
|
|
||||||
|
avatar.classList.add('hidden');
|
||||||
|
camBadge.classList.add('hidden');
|
||||||
|
stage.innerHTML = '';
|
||||||
|
|
||||||
|
const el = track.attach();
|
||||||
|
el.className = 'h-full w-full object-cover';
|
||||||
|
el.dataset.participant = participant.identity;
|
||||||
|
stage.appendChild(el);
|
||||||
|
},
|
||||||
|
|
||||||
setupCaptionPipeline() {
|
setupCaptionPipeline() {
|
||||||
if (!this.liveCaptions || !this.config.captionUrl) return;
|
if (!this.liveCaptions || !this.config.captionUrl) return;
|
||||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
@@ -196,60 +463,68 @@ function meetRoom() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
attachTrack(track, participant) {
|
attachTrack(track, publication, participant) {
|
||||||
const grid = document.getElementById('video-grid');
|
if (track.kind !== Track.Kind.Video) return;
|
||||||
if (!grid || track.kind !== Track.Kind.Video) return;
|
|
||||||
|
|
||||||
const isScreen = track.source === Track.Source.ScreenShare;
|
const isScreen = publication?.source === Track.Source.ScreenShare
|
||||||
const container = isScreen ? document.getElementById('screen-share') : grid;
|
|| track.source === Track.Source.ScreenShare;
|
||||||
|
|
||||||
if (isScreen) {
|
if (isScreen) {
|
||||||
|
const container = document.getElementById('screen-share');
|
||||||
|
if (!container) return;
|
||||||
container.classList.remove('hidden');
|
container.classList.remove('hidden');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
}
|
const el = track.attach();
|
||||||
|
el.className = 'h-full w-full rounded-xl object-contain';
|
||||||
const el = track.attach();
|
|
||||||
el.className = isScreen
|
|
||||||
? 'h-full w-full rounded-xl object-contain'
|
|
||||||
: 'aspect-video w-full rounded-xl bg-slate-800 object-cover';
|
|
||||||
el.dataset.participant = participant.identity;
|
|
||||||
|
|
||||||
if (!isScreen) {
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'relative overflow-hidden rounded-xl bg-slate-800';
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'absolute bottom-2 left-2 rounded bg-black/60 px-2 py-0.5 text-xs';
|
|
||||||
label.textContent = participant.name || participant.identity;
|
|
||||||
wrapper.appendChild(el);
|
|
||||||
wrapper.appendChild(label);
|
|
||||||
container.appendChild(wrapper);
|
|
||||||
} else {
|
|
||||||
container.appendChild(el);
|
container.appendChild(el);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (publication?.isMuted) {
|
||||||
|
this.updateParticipantTile(participant);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.attachVideoToTile(participant, track);
|
||||||
},
|
},
|
||||||
|
|
||||||
syncParticipants() {
|
syncParticipants() {
|
||||||
if (!this.room) return;
|
if (!room) return;
|
||||||
const all = [this.room.localParticipant, ...this.room.remoteParticipants.values()];
|
const all = [room.localParticipant, ...room.remoteParticipants.values()];
|
||||||
this.participants = all.map((p) => ({ identity: p.identity, name: p.name }));
|
this.participants = all.map((p) => ({ identity: p.identity, name: p.name }));
|
||||||
|
all.forEach((participant) => this.updateParticipantTile(participant));
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleMute() {
|
async toggleMute() {
|
||||||
if (!this.room) return;
|
if (!room) return;
|
||||||
this.isMuted = !this.isMuted;
|
const enabled = room.localParticipant.isMicrophoneEnabled;
|
||||||
await this.room.localParticipant.setMicrophoneEnabled(!this.isMuted);
|
await room.localParticipant.setMicrophoneEnabled(!enabled);
|
||||||
|
this.syncLocalMediaState();
|
||||||
|
this.updateParticipantTile(room.localParticipant);
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleVideo() {
|
async toggleVideo() {
|
||||||
if (!this.room) return;
|
if (!room) return;
|
||||||
this.isVideoOff = !this.isVideoOff;
|
const enabled = room.localParticipant.isCameraEnabled;
|
||||||
await this.room.localParticipant.setCameraEnabled(!this.isVideoOff);
|
await room.localParticipant.setCameraEnabled(!enabled);
|
||||||
|
this.syncLocalMediaState();
|
||||||
|
|
||||||
|
const local = room.localParticipant;
|
||||||
|
const videoPub = Array.from(local.videoTrackPublications.values())
|
||||||
|
.find((pub) => pub.source !== Track.Source.ScreenShare);
|
||||||
|
|
||||||
|
if (!enabled && videoPub?.track) {
|
||||||
|
this.attachTrack(videoPub.track, videoPub, local);
|
||||||
|
} else {
|
||||||
|
videoPub?.track?.detach();
|
||||||
|
this.updateParticipantTile(local);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleScreenShare() {
|
async toggleScreenShare() {
|
||||||
if (!this.room) return;
|
if (!room) return;
|
||||||
this.isScreenSharing = !this.isScreenSharing;
|
this.isScreenSharing = !this.isScreenSharing;
|
||||||
await this.room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
|
||||||
if (!this.isScreenSharing) {
|
if (!this.isScreenSharing) {
|
||||||
const screen = document.getElementById('screen-share');
|
const screen = document.getElementById('screen-share');
|
||||||
if (screen) {
|
if (screen) {
|
||||||
@@ -279,13 +554,14 @@ function meetRoom() {
|
|||||||
if (data.message) this.appendChatMessage(data.message);
|
if (data.message) this.appendChatMessage(data.message);
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendReaction(emoji) {
|
async sendReaction(kind) {
|
||||||
|
const emoji = kind === 'heart' ? '❤️' : '👍';
|
||||||
await fetch(this.config.reactionUrl, {
|
await fetch(this.config.reactionUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
body: JSON.stringify({ emoji }),
|
body: JSON.stringify({ emoji }),
|
||||||
});
|
});
|
||||||
this.showFloatingReaction(emoji);
|
this.showFloatingReaction(kind);
|
||||||
},
|
},
|
||||||
|
|
||||||
async poll() {
|
async poll() {
|
||||||
@@ -324,9 +600,9 @@ function meetRoom() {
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
},
|
},
|
||||||
|
|
||||||
showFloatingReaction(emoji) {
|
showFloatingReaction(kind) {
|
||||||
const id = Date.now();
|
const id = Date.now();
|
||||||
this.floatingReactions.push({ id, emoji });
|
this.floatingReactions.push({ id, kind });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id);
|
this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|||||||
@@ -76,7 +76,10 @@
|
|||||||
|
|
||||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center gap-2">
|
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center gap-2">
|
||||||
<template x-for="reaction in floatingReactions" :key="reaction.id">
|
<template x-for="reaction in floatingReactions" :key="reaction.id">
|
||||||
<span class="animate-bounce text-3xl" x-text="reaction.emoji"></span>
|
<span class="flex h-10 w-10 animate-bounce items-center justify-center rounded-full bg-slate-800/90 text-sky-300 ring-1 ring-white/20">
|
||||||
|
<svg x-show="reaction.kind === 'thumbs'" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.278 2.118-.764.585-.486.96-1.15 1.05-1.875.09-.725-.03-1.46-.34-2.115-.31-.655-.8-1.2-1.41-1.56-.61-.36-1.31-.52-2.02-.46-.71.06-1.38.36-1.92.86-.54.5-.92 1.18-1.08 1.92h-.01A4.5 4.5 0 0 0 2.25 12c0 1.59.83 2.99 2.08 3.79.63.4 1.35.61 2.08.61h8.34c.98 0 1.93-.32 2.71-.91.78-.59 1.32-1.42 1.52-2.34l1.14-5.7c.1-.5-.02-1.02-.33-1.42-.31-.4-.78-.63-1.28-.63H9.75"/></svg>
|
||||||
|
<svg x-show="reaction.kind === 'heart'" class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z"/></svg>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -103,40 +106,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="shrink-0 border-t border-slate-800 px-4 py-4">
|
<footer class="shrink-0 border-t border-slate-800 px-4 py-4">
|
||||||
<div class="mx-auto flex max-w-3xl items-center justify-center gap-3">
|
<div class="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-2 sm:gap-3">
|
||||||
<button @click="toggleMute()" type="button"
|
<button @click="toggleMute()" type="button"
|
||||||
class="rounded-full p-3"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
title="Mute">
|
:title="isMuted ? 'Unmute' : 'Mute'">
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg>
|
<svg x-show="!isMuted" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg>
|
||||||
|
<svg x-show="isMuted" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleVideo()" type="button"
|
<button @click="toggleVideo()" type="button"
|
||||||
class="rounded-full p-3"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
title="Camera">
|
:title="isVideoOff ? 'Turn camera on' : 'Turn camera off'">
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/></svg>
|
<svg x-show="!isVideoOff" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/></svg>
|
||||||
|
<svg x-show="isVideoOff" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleScreenShare()" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600" title="Share screen">
|
<button @click="toggleScreenShare()" type="button"
|
||||||
|
class="rounded-full p-3 transition-colors"
|
||||||
|
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
|
title="Share screen">
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25"/></svg>
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="raiseHand()" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600" title="Raise hand">✋</button>
|
<button @click="raiseHand()" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600" title="Raise hand">
|
||||||
<button @click="sendReaction('👍')" type="button" class="rounded-full bg-slate-700 px-3 py-3 hover:bg-slate-600">👍</button>
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.05 4.575a1.575 1.575 0 1 0-3.15 0v3m3.15-3v-1.5a1.575 1.575 0 0 1 3.15 0v1.5m-3.15 3v6.75a1.575 1.575 0 0 0 3.15 0V10.5m-6.3 0H4.875A1.125 1.125 0 0 0 3.75 11.625v4.125c0 .621.504 1.125 1.125 1.125h2.25m8.25-9.75V6.75a1.575 1.575 0 0 1 3.15 0v2.25m-3.15 3v6.75a1.575 1.575 0 0 0 3.15 0V10.5"/></svg>
|
||||||
<button @click="sendReaction('❤️')" type="button" class="rounded-full bg-slate-700 px-3 py-3 hover:bg-slate-600">❤️</button>
|
</button>
|
||||||
<button @click="chatOpen = !chatOpen" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600 lg:hidden" title="Chat">💬</button>
|
<button @click="sendReaction('thumbs')" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600" title="Applause">
|
||||||
<button @click="featuresOpen = !featuresOpen" type="button" class="rounded-full bg-slate-700 px-3 py-3 text-xs hover:bg-slate-600">More</button>
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.278 2.118-.764.585-.486.96-1.15 1.05-1.875.09-.725-.03-1.46-.34-2.115-.31-.655-.8-1.2-1.41-1.56-.61-.36-1.31-.52-2.02-.46-.71.06-1.38.36-1.92.86-.54.5-.92 1.18-1.08 1.92h-.01A4.5 4.5 0 0 0 2.25 12c0 1.59.83 2.99 2.08 3.79.63.4 1.35.61 2.08.61h8.34c.98 0 1.93-.32 2.71-.91.78-.59 1.32-1.42 1.52-2.34l1.14-5.7c.1-.5-.02-1.02-.33-1.42-.31-.4-.78-.63-1.28-.63H9.75"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="sendReaction('heart')" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600" title="React">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="chatOpen = !chatOpen" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600 lg:hidden" title="Chat">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm3.75 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm3.75 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-8.25 3.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm3.75 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm3.75 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 0 1 .778-.332 48.294 48.294 0 0 0 5.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="featuresOpen = !featuresOpen" type="button"
|
||||||
|
class="rounded-full bg-slate-700 px-3 py-3 text-xs font-medium hover:bg-slate-600"
|
||||||
|
:class="featuresOpen ? 'ring-2 ring-indigo-400' : ''"
|
||||||
|
title="More options">More</button>
|
||||||
<input type="file" id="meet-file-input" class="hidden" @change="uploadFile">
|
<input type="file" id="meet-file-input" class="hidden" @change="uploadFile">
|
||||||
<button @click="document.getElementById('meet-file-input').click()" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600" title="Upload file">📎</button>
|
<button @click="document.getElementById('meet-file-input').click()" type="button" class="rounded-full bg-slate-700 p-3 hover:bg-slate-600" title="Upload file">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.114-8.962a1.875 1.875 0 1 1-2.652 2.652L8.552 18.32"/></svg>
|
||||||
|
</button>
|
||||||
@if ($participant->isHost())
|
@if ($participant->isHost())
|
||||||
<button @click="toggleRecording()" type="button"
|
<button @click="toggleRecording()" type="button"
|
||||||
class="rounded-full p-3"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isRecording ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isRecording ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
title="Record">
|
:title="isRecording ? 'Stop recording' : 'Start recording'">
|
||||||
<span class="text-xs font-bold">●</span>
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleLock()" type="button"
|
<button @click="toggleLock()" type="button"
|
||||||
class="rounded-full p-3"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isLocked ? 'bg-amber-600 hover:bg-amber-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isLocked ? 'bg-amber-600 hover:bg-amber-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
title="Lock meeting">🔒</button>
|
:title="isLocked ? 'Unlock meeting' : 'Lock meeting'">
|
||||||
|
<svg x-show="!isLocked" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 0 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/></svg>
|
||||||
|
<svg x-show="isLocked" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75v-6.75A2.25 2.25 0 0 0 20.25 9.75H3.75A2.25 2.25 0 0 0 1.5 12.75v6.75A2.25 2.25 0 0 0 3.75 21Z"/></svg>
|
||||||
|
</button>
|
||||||
@endif
|
@endif
|
||||||
<form method="POST" action="{{ route('meet.room.leave', $session) }}" class="inline">
|
<form method="POST" action="{{ route('meet.room.leave', $session) }}" class="inline">
|
||||||
@csrf
|
@csrf
|
||||||
|
|||||||
Reference in New Issue
Block a user