diff --git a/app/Services/Meet/Media/LiveKitProvider.php b/app/Services/Meet/Media/LiveKitProvider.php index 8002251..f40b315 100644 --- a/app/Services/Meet/Media/LiveKitProvider.php +++ b/app/Services/Meet/Media/LiveKitProvider.php @@ -2,6 +2,10 @@ namespace App\Services\Meet\Media; +use Agence104\LiveKit\AccessToken; +use Agence104\LiveKit\AccessTokenOptions; +use Agence104\LiveKit\VideoGrant; + class LiveKitProvider implements MediaProviderInterface { public function createRoom(string $roomName, array $options = []): void @@ -23,25 +27,26 @@ class LiveKitProvider implements MediaProviderInterface $apiKey = (string) config('meet.media.livekit.api_key'); $apiSecret = (string) config('meet.media.livekit.api_secret'); - $now = time(); - $payload = [ - 'iss' => $apiKey, - 'sub' => $identity, - '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, - ], - ]; + $tokenOptions = (new AccessTokenOptions()) + ->setIdentity($identity) + ->setName($displayName) + ->setTtl(3600); - 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 @@ -55,23 +60,4 @@ class LiveKitProvider implements MediaProviderInterface { return (string) config('meet.media.livekit.url'); } - - /** - * @param array $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), '+/', '-_'), '='); - } } diff --git a/composer.json b/composer.json index f74a254..a64166c 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.4", + "agence104/livekit-server-sdk": "^1.3", "chillerlan/php-qrcode": "^5.0", "laravel/framework": "^12.0", "laravel/sanctum": "^4.3", diff --git a/composer.lock b/composer.lock index 1f2b027..7eb4b29 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,59 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "529339feb28ae432869697327a78cb16", + "content-hash": "53f235949e861fffd4527a02185afa6e", "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", "version": "0.14.8", @@ -670,6 +721,72 @@ ], "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", "version": "v1.4.0", @@ -741,6 +858,50 @@ ], "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", "version": "v1.1.4", @@ -2762,123 +2923,83 @@ "time": "2026-02-16T23:10:27+00:00" }, { - "name": "paragonie/constant_time_encoding", - "version": "v3.1.3", + "name": "php-http/discovery", + "version": "1.20.0", "source": { "type": "git", - "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", "shasum": "" }, "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": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "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": { "psr-4": { - "ParagonIE\\ConstantTime\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com", - "role": "Maintainer" + "Http\\Discovery\\": "src/" }, - { - "name": "Steve 'Sc00bz' Thomas", - "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" + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] }, - "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/", "license": [ "MIT" ], "authors": [ { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com" + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.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": [ - "csprng", - "polyfill", - "pseudorandom", - "random" + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" ], "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" }, - "time": "2020-10-15T08:29:30+00:00" + "time": "2024-10-02T11:20:13+00:00" }, { "name": "phpoption/phpoption", @@ -2955,116 +3076,6 @@ ], "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", "version": "1.0.0", @@ -3376,6 +3387,62 @@ }, "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", "version": "3.0.2", @@ -6310,6 +6377,74 @@ }, "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", "version": "v5.6.3", @@ -8851,7 +8986,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.4" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index ba26479..316fd54 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -1,11 +1,16 @@ import Alpine from 'alpinejs'; -import { Room, RoomEvent, Track } from 'livekit-client'; +import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client'; 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 { - room: null, - isMuted: false, - isVideoOff: false, + isMuted: true, + isVideoOff: true, isScreenSharing: false, isRecording: false, isLocked: false, @@ -31,6 +36,9 @@ function meetRoom() { config: {}, init() { + if (initialized) return; + initialized = true; + const el = document.getElementById('meet-config'); if (!el) return; @@ -76,43 +84,302 @@ function meetRoom() { 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() { + if (connectPromise) { + return connectPromise; + } + + connectPromise = this.startLiveKitSession(); + try { - this.room = new Room({ adaptiveStream: true, dynacast: true }); - this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { - this.attachTrack(track, participant); + await connectPromise; + } catch (e) { + 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(); + if (track.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) { + this.updateParticipantTile(participant); + } }); - this.room.on(RoomEvent.ParticipantConnected, () => this.syncParticipants()); - this.room.on(RoomEvent.ParticipantDisconnected, () => this.syncParticipants()); - this.room.on(RoomEvent.Disconnected, () => { + room.on(RoomEvent.TrackMuted, (publication, participant) => { + if (publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) { + 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'; }); - 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) { - await this.room.localParticipant.enableCameraAndMicrophone(); - this.isMuted = false; - this.isVideoOff = false; + await this.enableLocalMedia(room); } else { this.connectionStatus = 'View-only audience mode'; this.isMuted = true; this.isVideoOff = true; } - this.connectionStatus = 'Connected'; - this.syncParticipants(); + 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) { 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 = ` +
+
+
+
+
+ +
+ + +
+
`; + 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() { if (!this.liveCaptions || !this.config.captionUrl) return; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; @@ -196,60 +463,68 @@ function meetRoom() { }); }, - attachTrack(track, participant) { - const grid = document.getElementById('video-grid'); - if (!grid || track.kind !== Track.Kind.Video) return; + attachTrack(track, publication, participant) { + if (track.kind !== Track.Kind.Video) return; - const isScreen = track.source === Track.Source.ScreenShare; - const container = isScreen ? document.getElementById('screen-share') : grid; + const isScreen = publication?.source === Track.Source.ScreenShare + || track.source === Track.Source.ScreenShare; if (isScreen) { + const container = document.getElementById('screen-share'); + if (!container) return; container.classList.remove('hidden'); container.innerHTML = ''; - } - - 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 { + const el = track.attach(); + el.className = 'h-full w-full rounded-xl object-contain'; container.appendChild(el); + return; } + + if (publication?.isMuted) { + this.updateParticipantTile(participant); + return; + } + + this.attachVideoToTile(participant, track); }, syncParticipants() { - if (!this.room) return; - const all = [this.room.localParticipant, ...this.room.remoteParticipants.values()]; + if (!room) return; + const all = [room.localParticipant, ...room.remoteParticipants.values()]; this.participants = all.map((p) => ({ identity: p.identity, name: p.name })); + all.forEach((participant) => this.updateParticipantTile(participant)); }, async toggleMute() { - if (!this.room) return; - this.isMuted = !this.isMuted; - await this.room.localParticipant.setMicrophoneEnabled(!this.isMuted); + if (!room) return; + const enabled = room.localParticipant.isMicrophoneEnabled; + await room.localParticipant.setMicrophoneEnabled(!enabled); + this.syncLocalMediaState(); + this.updateParticipantTile(room.localParticipant); }, async toggleVideo() { - if (!this.room) return; - this.isVideoOff = !this.isVideoOff; - await this.room.localParticipant.setCameraEnabled(!this.isVideoOff); + if (!room) return; + const enabled = room.localParticipant.isCameraEnabled; + 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() { - if (!this.room) return; + if (!room) return; this.isScreenSharing = !this.isScreenSharing; - await this.room.localParticipant.setScreenShareEnabled(this.isScreenSharing); + await room.localParticipant.setScreenShareEnabled(this.isScreenSharing); if (!this.isScreenSharing) { const screen = document.getElementById('screen-share'); if (screen) { @@ -279,13 +554,14 @@ function meetRoom() { if (data.message) this.appendChatMessage(data.message); }, - async sendReaction(emoji) { + async sendReaction(kind) { + const emoji = kind === 'heart' ? '❤️' : '👍'; await fetch(this.config.reactionUrl, { method: 'POST', headers: this.headers(), body: JSON.stringify({ emoji }), }); - this.showFloatingReaction(emoji); + this.showFloatingReaction(kind); }, async poll() { @@ -324,9 +600,9 @@ function meetRoom() { container.scrollTop = container.scrollHeight; }, - showFloatingReaction(emoji) { + showFloatingReaction(kind) { const id = Date.now(); - this.floatingReactions.push({ id, emoji }); + this.floatingReactions.push({ id, kind }); setTimeout(() => { this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id); }, 2000); diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 0bad45a..bd29cee 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -76,7 +76,10 @@
@@ -103,40 +106,61 @@