diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 345f8c9..9f1bf72 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -2,7 +2,7 @@ name: Build on: pull_request: {} push: - branches: [livekit] + branches: [livekit, full-mesh] jobs: build: name: Build diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 161557d..f9793f2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,6 +1,8 @@ name: Run jest tests on: pull_request: {} + push: + branches: [livekit, full-mesh] jobs: jest: name: Run jest tests @@ -16,3 +18,7 @@ jobs: run: "yarn install" - name: Jest run: "yarn run test" + - name: Upload to codecov + uses: codecov/codecov-action@v3 + with: + flags: unittests diff --git a/package.json b/package.json index 6a077d0..360c84c 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,11 @@ "\\.(css|less|svg)+$": "identity-obj-proxy", "^\\./IndexedDBWorker\\?worker$": "/test/mocks/workerMock.ts", "^\\./olm$": "/test/mocks/olmMock.ts" - } + }, + "collectCoverage": true, + "coverageReporters": [ + "text", + "cobertura" + ] } } diff --git a/public/locales/bg/app.json b/public/locales/bg/app.json index 0aea00e..4cf4665 100644 --- a/public/locales/bg/app.json +++ b/public/locales/bg/app.json @@ -53,7 +53,6 @@ "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Други потребители се опитват да се присъединят в разговора от несъвместими версии. Следните потребители трябва да проверят дали са презаредили браузърите си<1>{userLis}", "Password": "Парола", "Passwords must match": "Паролите не съвпадат", - "Press and hold to talk over {{name}}": "Натиснете и задръжте за да говорите заедно с {{name}}", "Profile": "Профил", "Recaptcha dismissed": "Recaptcha отхвърлена", "Recaptcha not loaded": "Recaptcha не е заредена", diff --git a/public/locales/cs/app.json b/public/locales/cs/app.json index 91f1c36..40bcd5a 100644 --- a/public/locales/cs/app.json +++ b/public/locales/cs/app.json @@ -62,7 +62,6 @@ "Inspector": "Insepktor", "Incompatible versions!": "Nekompatibilní verze!", "Incompatible versions": "Nekompatibilní verze", - "{{count}} people connected|other": "{{count}} lidí připojeno", "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>Terms and conditions": "Tato stárnka je chráněna pomocí ReCAPTCHA a Google <2>zásad ochrany osobních údajů a <6>podmínky služby platí.<9>Kliknutím na \"Registrovat\", souhlasíte s <12>Pravidly a podmínkami", "Walkie-talkie call name": "Jméno vysílačkového hovoru", "Walkie-talkie call": "Vysílačkový hovor", diff --git a/public/locales/de/app.json b/public/locales/de/app.json index c7eb44e..ef6a8f3 100644 --- a/public/locales/de/app.json +++ b/public/locales/de/app.json @@ -52,7 +52,6 @@ "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Andere Benutzer versuchen, diesem Aufruf von einer inkompatiblen Softwareversion aus beizutreten. Diese Benutzer sollten ihre Web-Browser Seite neu laden:<1>{userLis}", "Password": "Passwort", "Passwords must match": "Passwörter müssen übereinstimmen", - "Press and hold to talk over {{name}}": "Zum Verdrängen von {{name}} und Sprechen gedrückt halten", "Profile": "Profil", "Recaptcha dismissed": "Recaptcha abgelehnt", "Recaptcha not loaded": "Recaptcha nicht geladen", diff --git a/public/locales/el/app.json b/public/locales/el/app.json index 79157df..d61ba14 100644 --- a/public/locales/el/app.json +++ b/public/locales/el/app.json @@ -71,6 +71,5 @@ "Close": "Κλείσιμο", "Change layout": "Αλλαγή διάταξης", "Camera": "Κάμερα", - "Audio": "Ήχος", - "{{name}} (Connecting...)": "{{name}} (Συνδέεται...)" + "Audio": "Ήχος" } diff --git a/public/locales/es/app.json b/public/locales/es/app.json index 318ece5..facea46 100644 --- a/public/locales/es/app.json +++ b/public/locales/es/app.json @@ -94,7 +94,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Crear una cuenta o <2>Acceder como invitado", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>Unirse ahora<1>Or<2>Copiar el enlace y unirse más tarde", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>¿Ya tienes una cuenta?<1><0>Iniciar sesión o <2>Acceder como invitado", - "{{name}} (Connecting...)": "{{name}} (Conectando...)", "Element Call Home": "Inicio de Element Call", "Copy": "Copiar", "<0>Submitting debug logs will help us track down the problem.": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.", diff --git a/public/locales/et/app.json b/public/locales/et/app.json index 2b5fcbf..8c6a4af 100644 --- a/public/locales/et/app.json +++ b/public/locales/et/app.json @@ -4,7 +4,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Loo konto Või <2>Sisene külalisena", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>On sul juba konto?<1><0>Logi sisse Või <2>Logi sisse külalisena", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{count}} people connected|other": "{{count}} osalejat liitunud", "Invite people": "Kutsu inimesi", "Invite": "Kutsu", "Inspector": "Inspektor", @@ -114,5 +113,7 @@ "How did it go?": "Kuidas sujus?", "{{displayName}}, your call has ended.": "{{displayName}}, sinu kõne on lõppenud.", "<0>Thanks for your feedback!": "<0>Täname Sind tagasiside eest!", - "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Meie rakenduse paremaks muutmiseks me hea meelega ootame Sinu arvamusi." + "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Meie rakenduse paremaks muutmiseks me hea meelega ootame Sinu arvamusi.", + "Show connection stats": "Näita ühenduse statistikat", + "{{displayName}} is presenting": "{{displayName}} on esitlemas" } diff --git a/public/locales/fa/app.json b/public/locales/fa/app.json index 9006512..0877ab7 100644 --- a/public/locales/fa/app.json +++ b/public/locales/fa/app.json @@ -54,7 +54,6 @@ "<0>Why not finish by setting up a password to keep your account?<1>You'll be able to keep your name and set an avatar for use on future calls": "<0>چرا یک رمز عبور برای حساب کاربری خود تنظیم نمی‌کنید؟<1>شما می‌توانید نام خود را حفظ کنید و یک آواتار برای تماس‌های آینده بسازید", "<0>Create an account Or <2>Access as a guest": "<0>ساخت حساب کاربری Or <2>دسترسی به عنوان میهمان", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>از قبل حساب کاربری دارید؟<1><0>ورود Or <2>به عنوان یک میهمان وارد شوید", - "{{count}} people connected|other": "{{count}} نفر متصل هستند", "Local volume": "حجم داخلی", "Inspector": "بازرس", "Incompatible versions!": "نسخه‌های ناسازگار!", @@ -72,7 +71,6 @@ "Register": "ثبت‌نام", "Recaptcha not loaded": "کپچا بارگیری نشد", "Recaptcha dismissed": "ریکپچا رد شد", - "Press and hold spacebar to talk": "برای صحبت کردن کلید فاصله را فشار داده و نگه دارید", "Passwords must match": "رمز عبور باید همخوانی داشته باشد", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "کاربران دیگر تلاش می‌کنند با ورژن‌های ناسازگار به مکالمه بپیوندند. این کاربران باید از بروزرسانی مرورگرشان اطمینان داشته باشند:<1>{userLis}", "Not registered yet? <2>Create an account": "هنوز ثبت‌نام نکرده‌اید؟ <2>ساخت حساب کاربری", diff --git a/public/locales/fr/app.json b/public/locales/fr/app.json index 91b3ba7..837c677 100644 --- a/public/locales/fr/app.json +++ b/public/locales/fr/app.json @@ -50,7 +50,6 @@ "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Des utilisateurs essayent de rejoindre cet appel à partir de versions incompatibles. Ces utilisateurs doivent rafraîchir la page dans leur navigateur : <1>{userLis}", "Password": "Mot de passe", "Passwords must match": "Les mots de passe doivent correspondre", - "Press and hold to talk over {{name}}": "Appuyez et maintenez enfoncé pour parler par dessus {{name}}", "Profile": "Profil", "Recaptcha dismissed": "Recaptcha refusé", "Recaptcha not loaded": "Recaptcha non chargé", @@ -114,5 +113,7 @@ "{{count}} stars|one": "{{count}} favori", "{{displayName}}, your call has ended.": "{{displayName}}, votre appel est terminé.", "<0>Thanks for your feedback!": "<0>Merci pour votre commentaire !", - "How did it go?": "Comment cela s’est-il passé ?" + "How did it go?": "Comment cela s’est-il passé ?", + "{{displayName}} is presenting": "{{displayName}} est à l’écran", + "Show connection stats": "Afficher les statistiques de la connexion" } diff --git a/public/locales/id/app.json b/public/locales/id/app.json index 9d891b3..1388dae 100644 --- a/public/locales/id/app.json +++ b/public/locales/id/app.json @@ -53,7 +53,6 @@ "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Pengguna lain sedang mencoba bergabung ke panggilan ini dari versi yang tidak kompatibel. Pengguna berikut seharusnya memastikan bahwa mereka telah memuat ulang peramban mereka: <1>{userLis}", "Password": "Kata sandi", "Passwords must match": "Kata sandi harus cocok", - "Press and hold to talk over {{name}}": "Tekan dan tahan untuk berbicara pada {{name}}", "Profile": "Profil", "Recaptcha dismissed": "Recaptcha ditutup", "Recaptcha not loaded": "Recaptcha tidak dimuat", @@ -113,5 +112,8 @@ "<0>Thanks for your feedback!": "<0>Terima kasih atas masukan Anda!", "How did it go?": "Bagaimana rasanya?", "{{count}} stars|one": "{{count}} bintang", - "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Kami ingin mendengar masukan Anda supaya kami bisa meningkatkan pengalaman Anda." + "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Kami ingin mendengar masukan Anda supaya kami bisa meningkatkan pengalaman Anda.", + "Show connection stats": "Tampilkan statistik koneksi", + "{{displayName}} is presenting": "{{displayName}} sedang menampilkan", + "{{count}} stars|other": "{{count}} bintang" } diff --git a/public/locales/ja/app.json b/public/locales/ja/app.json index 0687914..9aac334 100644 --- a/public/locales/ja/app.json +++ b/public/locales/ja/app.json @@ -1,5 +1,4 @@ { - "{{name}} (Waiting for video...)": "{{name}}(ビデオを待機しています…)", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>既にアカウントをお持ちですか?<1><0>ログインまたは<2>ゲストとしてアクセス", "<0>Create an account Or <2>Access as a guest": "<0>アカウントを作成または<2>ゲストとしてアクセス", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>今すぐ通話に参加<1>または<2>通話リンクをコピーし、後で参加", diff --git a/public/locales/pl/app.json b/public/locales/pl/app.json index c8ae0a8..3e2b1d4 100644 --- a/public/locales/pl/app.json +++ b/public/locales/pl/app.json @@ -40,7 +40,6 @@ "Recaptcha not loaded": "Recaptcha nie została załadowana", "Recaptcha dismissed": "Recaptcha odrzucona", "Profile": "Profil", - "Press and hold spacebar to talk": "Przytrzymaj spację, aby mówić", "Passwords must match": "Hasła muszą pasować", "Password": "Hasło", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Inni użytkownicy próbują dołączyć do tego połączenia przy użyciu niekompatybilnych wersji. Powinni oni upewnić się, że odświeżyli stronę w swoich przeglądarkach:<1>{userLis}", @@ -93,7 +92,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Utwórz konto lub <2>Dołącz jako gość", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>Masz już konto?<1><0>Zaloguj się lub <2>Dołącz jako gość", "{{names}}, {{name}}": "{{names}}, {{name}}", - "This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.", "Copy": "Kopiuj", "<0>Submitting debug logs will help us track down the problem.": "<0>Wysłanie dzienników debuggowania pomoże nam ustalić przyczynę problemu.", "<0>Oops, something's gone wrong.": "<0>Ojej, coś poszło nie tak.", diff --git a/public/locales/ru/app.json b/public/locales/ru/app.json index 3041cce..5d20cb6 100644 --- a/public/locales/ru/app.json +++ b/public/locales/ru/app.json @@ -10,7 +10,6 @@ "Submit feedback": "Отправить отзыв", "Sending debug logs…": "Отправка журнала отладки…", "Select an option": "Выберите вариант", - "Press and hold spacebar to talk over {{name}}": "Чтобы говорить поверх участника {{name}}, нажмите и удерживайте [Пробел]", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Другие пользователи пытаются присоединиться с неподдерживаемых версий программы. Этим участникам надо перезагрузить браузер: <1>{userLis}", "Grid layout menu": "Меню \"Расположение сеткой\"", "By clicking \"Join call now\", you agree to our <2>Terms and conditions": "Нажимая \"Присоединиться сейчас\", вы соглашаетесь с нашими <2>положениями и условиями", @@ -94,7 +93,6 @@ "Call link copied": "Ссылка на звонок скопирована", "Avatar": "Аватар", "Audio": "Аудио", - "{{name}} is presenting": "{{name}} показывает", "Element Call Home": "Главная Element Call", "Copy": "Копировать", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>Присоединиться сейчас к звонку<1>или<1><2>Скопировать ссылку на звонок и присоединиться позже", diff --git a/public/locales/sk/app.json b/public/locales/sk/app.json index bae6aac..57b0e26 100644 --- a/public/locales/sk/app.json +++ b/public/locales/sk/app.json @@ -22,7 +22,6 @@ "Recaptcha not loaded": "Recaptcha sa nenačítala", "Recaptcha dismissed": "Recaptcha zamietnutá", "Profile": "Profil", - "Press and hold spacebar to talk": "Stlačte a podržte medzerník, ak chcete hovoriť", "Passwords must match": "Heslá sa musia zhodovať", "Password": "Heslo", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Ostatní používatelia sa pokúšajú pripojiť k tomuto hovoru z nekompatibilných verzií. Títo používatelia by sa mali uistiť, že si obnovili svoje prehliadače:<1>{userLis}", @@ -97,7 +96,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Vytvoriť konto Alebo <2>Prihlásiť sa ako hosť", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>Už máte konto?<1><0>Prihláste sa Alebo <2>Prihlásiť sa ako hosť", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{name}} (Connecting...)": "{{name}} (Pripájanie...)", "<0>Submitting debug logs will help us track down the problem.": "<0>Odoslanie záznamov ladenia nám pomôže nájsť problém.", "<0>Oops, something's gone wrong.": "<0>Hups, niečo sa pokazilo.", "Expose developer settings in the settings window.": "Zobraziť nastavenia pre vývojárov v okne nastavení.", @@ -115,5 +113,7 @@ "{{count}} stars|other": "{{count}} hviezdičiek", "{{displayName}}, your call has ended.": "{{displayName}}, váš hovor skončil.", "<0>Thanks for your feedback!": "<0> Ďakujeme za vašu spätnú väzbu!", - "<0>We'd love to hear your feedback so we can improve your experience.": "<0> Radi si vypočujeme vašu spätnú väzbu, aby sme mohli zlepšiť vaše skúsenosti." + "<0>We'd love to hear your feedback so we can improve your experience.": "<0> Radi si vypočujeme vašu spätnú väzbu, aby sme mohli zlepšiť vaše skúsenosti.", + "{{displayName}} is presenting": "{{displayName}} prezentuje", + "Show connection stats": "Zobraziť štatistiky pripojenia" } diff --git a/public/locales/uk/app.json b/public/locales/uk/app.json index 8e3a578..6e5ab0c 100644 --- a/public/locales/uk/app.json +++ b/public/locales/uk/app.json @@ -39,7 +39,6 @@ "Recaptcha not loaded": "Recaptcha не завантажено", "Recaptcha dismissed": "Recaptcha не пройдено", "Profile": "Профіль", - "Press and hold spacebar to talk": "Затисніть пробіл, щоб говорити", "Passwords must match": "Паролі відрізняються", "Password": "Пароль", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Інші користувачі намагаються приєднатися до цього виклику з несумісних версій. Ці користувачі повинні переконатися, що вони оновили сторінки своїх браузерів:<1>{userLis}", @@ -94,7 +93,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>Створити обліковий запис або <2>Отримати доступ як гість", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>Уже маєте обліковий запис?<1><0>Увійти Or <2>Отримати доступ як гість", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{count}} people connected|one": "{{count}} під'єднується", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>Приєднатися до виклику зараз<1>Or<2>Скопіювати посилання на виклик і приєднатися пізніше", "Element Call Home": "Домівка Element Call", "Copy": "Копіювати", @@ -115,5 +113,7 @@ "{{count}} stars|other": "{{count}} зірок", "{{displayName}}, your call has ended.": "{{displayName}}, ваш виклик завершено.", "<0>We'd love to hear your feedback so we can improve your experience.": "<0>Ми будемо раді почути ваші відгуки, щоб поліпшити роботу застосунку.", - "How did it go?": "Вам усе сподобалось?" + "How did it go?": "Вам усе сподобалось?", + "{{displayName}} is presenting": "{{displayName}} представляє", + "Show connection stats": "Показати стан з'єднання" } diff --git a/public/locales/zh-Hans/app.json b/public/locales/zh-Hans/app.json index 46f6a9a..a7126f8 100644 --- a/public/locales/zh-Hans/app.json +++ b/public/locales/zh-Hans/app.json @@ -31,7 +31,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>创建账户 Or <2>以访客身份继续", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>已有账户?<1><0>登录 Or <2>以访客身份继续", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{name}} (Connecting...)": "{{name}} (正在连接……)", "Inspector": "检查器", "Show call inspector": "显示通话检查器", "Share screen": "屏幕共享", @@ -47,7 +46,6 @@ "Recaptcha not loaded": "reCaptcha未加载", "Recaptcha dismissed": "reCaptcha验证失败", "Profile": "个人信息", - "Press and hold spacebar to talk": "按住空格键发言", "Passwords must match": "密码必须匹配", "Password": "密码", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "其他用户正试图从不兼容的版本加入这一呼叫。这些用户应该确保已经刷新了浏览器:<1>{userLis}", diff --git a/public/locales/zh-Hant/app.json b/public/locales/zh-Hant/app.json index 5ac0980..b507a75 100644 --- a/public/locales/zh-Hant/app.json +++ b/public/locales/zh-Hant/app.json @@ -3,7 +3,6 @@ "<0>Create an account Or <2>Access as a guest": "<0>建立帳號 或<2>以訪客身份登入", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>已經有帳號?<1><0>登入 或<2>以訪客身份登入", "{{names}}, {{name}}": "{{names}}, {{name}}", - "{{name}} (Connecting...)": "{{name}} (連結中...)", "Expose developer settings in the settings window.": "在設定視窗中顯示開發者設定。", "Developer Settings": "開發者設定", "<0>Submitting debug logs will help us track down the problem.": "<0>送出除錯紀錄,可幫助我們修正問題。", @@ -47,7 +46,6 @@ "Recaptcha not loaded": "驗證碼未載入", "Recaptcha dismissed": "略過驗證碼", "Profile": "個人檔案", - "Press and hold spacebar to talk": "說話時請按住空白鍵", "Passwords must match": "密碼必須相符", "Password": "密碼", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "有使用者試著加入通話,但他們的軟體版本不相容。這些使用者需要確認已將瀏覽器更新到最新版本:<1>{userLis}", @@ -115,5 +113,7 @@ "<0>We'd love to hear your feedback so we can improve your experience.": "<0>我們想要聽到您的回饋,如此我們才能改善您的體驗。", "{{count}} stars|one": "{{count}} 個星星", "{{displayName}}, your call has ended.": "{{displayName}},您的通話已結束。", - "How did it go?": "進展如何?" + "How did it go?": "進展如何?", + "{{displayName}} is presenting": "{{displayName}} 正在展示", + "Show connection stats": "顯示連線統計資料" } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 57e49f5..d1653ac 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -16,6 +16,8 @@ limitations under the License. import { ResizeObserver } from "@juggle/resize-observer"; import { + RoomAudioRenderer, + RoomContext, useLocalParticipant, useParticipants, useTracks, @@ -80,6 +82,8 @@ import { VideoTile } from "../video-grid/VideoTile"; import { UserChoices, useLiveKit } from "../livekit/useLiveKit"; import { useMediaDevices } from "../livekit/useMediaDevices"; import { SFUConfig } from "../livekit/openIDSFU"; +import { useFullscreen } from "./useFullscreen"; +import { useLayoutStates } from "../video-grid/Layout"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); // There is currently a bug in Safari our our code with cloning and sending MediaStreams @@ -95,7 +99,13 @@ export interface ActiveCallProps extends Omit { export function ActiveCall(props: ActiveCallProps) { const livekitRoom = useLiveKit(props.userChoices, props.sfuConfig); - return livekitRoom && ; + return ( + livekitRoom && ( + + + + ) + ); } export interface InCallViewProps { @@ -167,9 +177,6 @@ export function InCallView({ const toggleCamera = useCallback(async () => { await localParticipant.setCameraEnabled(!isCameraEnabled); }, [localParticipant, isCameraEnabled]); - const toggleScreensharing = useCallback(async () => { - await localParticipant.setScreenShareEnabled(!isScreenShareEnabled); - }, [localParticipant, isScreenShareEnabled]); const joinRule = useJoinRule(groupCall.room); @@ -222,15 +229,19 @@ export function InCallView({ const noControls = reducedControls && bounds.height <= 400; const items = useParticipantTiles(livekitRoom, participants); + const { fullscreenItem, toggleFullscreen, exitFullscreen } = + useFullscreen(items); - // The maximised participant is the focused (active) participant, given the + // The maximised participant: either the participant that the user has + // manually put in fullscreen, or the focused (active) participant if the // window is too small to show everyone const maximisedParticipant = useMemo( () => - noControls - ? items.find((item) => item.focused) ?? items.at(0) ?? null - : null, - [noControls, items] + fullscreenItem ?? + (noControls + ? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null + : null), + [fullscreenItem, noControls, items] ); const Grid = @@ -238,6 +249,10 @@ export function InCallView({ const prefersReducedMotion = usePrefersReducedMotion(); + // This state is lifted out of NewVideoGrid so that layout states can be + // restored after a layout switch or upon exiting fullscreen + const layoutStates = useLayoutStates(); + const renderContent = (): JSX.Element => { if (items.length === 0) { return ( @@ -249,6 +264,9 @@ export function InCallView({ if (maximisedParticipant) { return ( {(props) => ( 2} showConnectionStats={showConnectionStats} {...props} @@ -316,6 +338,11 @@ export function InCallView({ [styles.maximised]: undefined, }); + const toggleScreensharing = useCallback(async () => { + exitFullscreen(); + await localParticipant.setScreenShareEnabled(!isScreenShareEnabled); + }, [localParticipant, isScreenShareEnabled, exitFullscreen]); + let footer: JSX.Element | null; if (noControls) { @@ -349,9 +376,7 @@ export function InCallView({ /> ); } - if (!maximisedParticipant) { - buttons.push(); - } + buttons.push(); } buttons.push( @@ -362,7 +387,7 @@ export function InCallView({ return (
- {!hideHeader && ( + {!hideHeader && maximisedParticipant === null && (
)}
+ {renderContent()} {footer}
@@ -464,6 +490,7 @@ function useParticipantTiles( local: sfuParticipant.isLocal, largeBaseSize: false, data: { + id, member, sfuParticipant, content: TileContent.UserMedia, @@ -473,14 +500,16 @@ function useParticipantTiles( // If there is a screen sharing enabled for this participant, create a tile for it as well. let screenShareTile: TileDescriptor | undefined; if (sfuParticipant.isScreenShareEnabled) { + const screenShareId = `${id}:screen-share`; screenShareTile = { ...userMediaTile, - id: `${id}:screen-share`, + id: screenShareId, focused: true, largeBaseSize: true, placeNear: id, data: { ...userMediaTile.data, + id: screenShareId, content: TileContent.ScreenShare, }, }; diff --git a/src/room/useFullscreen.ts b/src/room/useFullscreen.ts new file mode 100644 index 0000000..78ec1c8 --- /dev/null +++ b/src/room/useFullscreen.ts @@ -0,0 +1,114 @@ +/* +Copyright 2023 New Vector Ltd + + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { logger } from "matrix-js-sdk/src/logger"; +import { useCallback, useLayoutEffect, useRef } from "react"; + +import { TileDescriptor } from "../video-grid/VideoGrid"; +import { useReactiveState } from "../useReactiveState"; +import { useEventTarget } from "../useEvents"; + +const isFullscreen = () => + Boolean(document.fullscreenElement) || + Boolean(document.webkitFullscreenElement); + +function enterFullscreen() { + if (document.body.requestFullscreen) { + document.body.requestFullscreen(); + } else if (document.body.webkitRequestFullscreen) { + document.body.webkitRequestFullscreen(); + } else { + logger.error("No available fullscreen API!"); + } +} + +function exitFullscreen() { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else { + logger.error("No available fullscreen API!"); + } +} + +function useFullscreenChange(onFullscreenChange: () => void) { + useEventTarget(document.body, "fullscreenchange", onFullscreenChange); + useEventTarget(document.body, "webkitfullscreenchange", onFullscreenChange); +} + +/** + * Provides callbacks for controlling the full-screen view, which can hold one + * item at a time. + */ +export function useFullscreen(items: TileDescriptor[]): { + fullscreenItem: TileDescriptor | null; + toggleFullscreen: (itemId: string) => void; + exitFullscreen: () => void; +} { + const [fullscreenItem, setFullscreenItem] = + useReactiveState | null>( + (prevItem) => + prevItem == null + ? null + : items.find((i) => i.id === prevItem.id) ?? null, + [items] + ); + + const latestItems = useRef[]>(items); + latestItems.current = items; + + const latestFullscreenItem = useRef | null>(fullscreenItem); + latestFullscreenItem.current = fullscreenItem; + + const toggleFullscreen = useCallback( + (itemId: string) => { + setFullscreenItem( + latestFullscreenItem.current === null + ? latestItems.current.find((i) => i.id === itemId) ?? null + : null + ); + }, + [setFullscreenItem] + ); + + const exitFullscreenCallback = useCallback( + () => setFullscreenItem(null), + [setFullscreenItem] + ); + + useLayoutEffect(() => { + // Determine whether we need to change the fullscreen state + if (isFullscreen() !== (fullscreenItem !== null)) { + (fullscreenItem === null ? exitFullscreen : enterFullscreen)(); + } + }, [fullscreenItem]); + + // Detect when the user exits fullscreen through an external mechanism like + // browser chrome or the escape key + useFullscreenChange( + useCallback(() => { + if (!isFullscreen()) setFullscreenItem(null); + }, [setFullscreenItem]) + ); + + return { + fullscreenItem, + toggleFullscreen, + exitFullscreen: exitFullscreenCallback, + }; +} diff --git a/src/video-grid/BigGrid.module.css b/src/video-grid/BigGrid.module.css new file mode 100644 index 0000000..cde593a --- /dev/null +++ b/src/video-grid/BigGrid.module.css @@ -0,0 +1,29 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.bigGrid { + display: grid; + grid-auto-rows: 163px; + gap: 8px; +} + +@media (min-width: 800px) { + .bigGrid { + grid-auto-rows: 183px; + column-gap: 18px; + row-gap: 21px; + } +} diff --git a/src/video-grid/model.ts b/src/video-grid/BigGrid.tsx similarity index 74% rename from src/video-grid/model.ts rename to src/video-grid/BigGrid.tsx index 974a755..f08676f 100644 --- a/src/video-grid/model.ts +++ b/src/video-grid/BigGrid.tsx @@ -15,33 +15,48 @@ limitations under the License. */ import TinyQueue from "tinyqueue"; +import { RectReadOnly } from "react-use-measure"; +import { FC, memo, ReactNode } from "react"; +import React from "react"; import { TileDescriptor } from "./VideoGrid"; +import { Slot } from "./NewVideoGrid"; +import { Layout } from "./Layout"; import { count, findLastIndex } from "../array-utils"; +import styles from "./BigGrid.module.css"; /** * A 1×1 cell in a grid which belongs to a tile. */ -export interface Cell { +interface Cell { /** * The item displayed on the tile. */ - item: TileDescriptor; + readonly item: TileDescriptor; /** * Whether this cell is the origin (top left corner) of the tile. */ - origin: boolean; + readonly origin: boolean; /** * The width, in columns, of the tile. */ - columns: number; + readonly columns: number; /** * The height, in rows, of the tile. */ - rows: number; + readonly rows: number; } -export interface Grid { +export interface BigGridState { + readonly columns: number; + /** + * The cells of the grid, in left-to-right top-to-bottom order. + * undefined = empty. + */ + readonly cells: (Cell | undefined)[]; +} + +interface MutableBigGridState { columns: number; /** * The cells of the grid, in left-to-right top-to-bottom order. @@ -58,7 +73,7 @@ export interface Grid { * @returns An array in which each cell holds the index of the next cell to move * to to reach the destination, or null if it is the destination. */ -export function getPaths(dest: number, g: Grid): (number | null)[] { +export function getPaths(dest: number, g: BigGridState): (number | null)[] { const destRow = row(dest, g); const destColumn = column(dest, g); @@ -106,18 +121,23 @@ export function getPaths(dest: number, g: Grid): (number | null)[] { return edges as (number | null)[]; } -const findLast1By1Index = (g: Grid): number | null => +const findLast1By1Index = (g: BigGridState): number | null => findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1); -export function row(index: number, g: Grid): number { +export function row(index: number, g: BigGridState): number { return Math.floor(index / g.columns); } -export function column(index: number, g: Grid): number { +export function column(index: number, g: BigGridState): number { return ((index % g.columns) + g.columns) % g.columns; } -function inArea(index: number, start: number, end: number, g: Grid): boolean { +function inArea( + index: number, + start: number, + end: number, + g: BigGridState +): boolean { const indexColumn = column(index, g); const indexRow = row(index, g); return ( @@ -131,7 +151,7 @@ function inArea(index: number, start: number, end: number, g: Grid): boolean { function* cellsInArea( start: number, end: number, - g: Grid + g: BigGridState ): Generator { const startColumn = column(start, g); const endColumn = column(end, g); @@ -149,7 +169,7 @@ function* cellsInArea( export function forEachCellInArea( start: number, end: number, - g: Grid, + g: BigGridState, fn: (c: Cell | undefined, i: number) => void ): void { for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i); @@ -158,7 +178,7 @@ export function forEachCellInArea( function allCellsInArea( start: number, end: number, - g: Grid, + g: BigGridState, fn: (c: Cell | undefined, i: number) => boolean ): boolean { for (const i of cellsInArea(start, end, g)) { @@ -172,16 +192,19 @@ const areaEnd = ( start: number, columns: number, rows: number, - g: Grid + g: BigGridState ): number => start + columns - 1 + g.columns * (rows - 1); -const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] }); +const cloneGrid = (g: BigGridState): BigGridState => ({ + ...g, + cells: [...g.cells], +}); /** * Gets the index of the next gap in the grid that should be backfilled by 1×1 * tiles. */ -function getNextGap(g: Grid): number | null { +function getNextGap(g: BigGridState): number | null { const last1By1Index = findLast1By1Index(g); if (last1By1Index === null) return null; @@ -204,7 +227,7 @@ function getNextGap(g: Grid): number | null { /** * Gets the index of the origin of the tile to which the given cell belongs. */ -function getOrigin(g: Grid, index: number): number { +function getOrigin(g: BigGridState, index: number): number { const initialColumn = column(index, g); for ( @@ -229,7 +252,7 @@ function getOrigin(g: Grid, index: number): number { * along the way. * Precondition: the destination area must consist of only 1×1 tiles. */ -function moveTile(g: Grid, from: number, to: number) { +function moveTileUnchecked(g: BigGridState, from: number, to: number) { const tile = g.cells[from]!; const fromEnd = areaEnd(from, tile.columns, tile.rows, g); const toEnd = areaEnd(to, tile.columns, tile.rows, g); @@ -262,10 +285,15 @@ function moveTile(g: Grid, from: number, to: number) { /** * Moves the tile at index "from" over to index "to", if there is space. */ -export function tryMoveTile(g: Grid, from: number, to: number): Grid { +export function moveTile( + g: BigGridState, + from: number, + to: number +): BigGridState { const tile = g.cells[from]!; if ( + to !== from && // Skip the operation if nothing would move to >= 0 && to < g.cells.length && column(to, g) <= g.columns - tile.columns @@ -283,7 +311,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid { if (allCellsInArea(to, toEnd, g, displaceable)) { // The target space is free; move const gClone = cloneGrid(g); - moveTile(gClone, from, to); + moveTileUnchecked(gClone, from, to); return gClone; } } @@ -297,7 +325,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid { * enlarged tiles around when necessary. * @returns Whether the tile was actually pushed */ -function pushTileUp(g: Grid, from: number): boolean { +function pushTileUp(g: BigGridState, from: number): boolean { const tile = g.cells[from]!; // TODO: pushing large tiles sideways might be more successful in some @@ -315,7 +343,7 @@ function pushTileUp(g: Grid, from: number): boolean { ); if (cellsAboveAreDisplacable) { - moveTile(g, from, from - g.columns); + moveTileUnchecked(g, from, from - g.columns); return true; } else { return false; @@ -325,8 +353,8 @@ function pushTileUp(g: Grid, from: number): boolean { /** * Backfill any gaps in the grid. */ -export function fillGaps(g: Grid): Grid { - const result = cloneGrid(g); +export function fillGaps(g: BigGridState): BigGridState { + const result = cloneGrid(g) as MutableBigGridState; // This will hopefully be the size of the grid after we're done here, assuming // that we can pack the large tiles tightly enough @@ -403,7 +431,11 @@ export function fillGaps(g: Grid): Grid { return result; } -function createRows(g: Grid, count: number, atRow: number): Grid { +function createRows( + g: BigGridState, + count: number, + atRow: number +): BigGridState { const result = { columns: g.columns, cells: new Array(g.cells.length + g.columns * count), @@ -430,9 +462,12 @@ function createRows(g: Grid, count: number, atRow: number): Grid { } /** - * Adds a set of new items into the grid. + * Adds a set of new items into the grid. (May leave gaps.) */ -export function addItems(items: TileDescriptor[], g: Grid): Grid { +export function addItems( + items: TileDescriptor[], + g: BigGridState +): BigGridState { let result = cloneGrid(g); for (const item of items) { @@ -444,13 +479,11 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { }; let placeAt: number; - let hasGaps: boolean; if (item.placeNear === undefined) { // This item has no special placement requests, so let's put it // uneventfully at the end of the grid placeAt = result.cells.length; - hasGaps = false; } else { // This item wants to be placed near another; let's put it on a row // directly below the related tile @@ -460,7 +493,6 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { if (placeNear === -1) { // Can't find the related tile, so let's give up and place it at the end placeAt = result.cells.length; - hasGaps = false; } else { const placeNearCell = result.cells[placeNear]!; const placeNearEnd = areaEnd( @@ -475,7 +507,6 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { placeNear + Math.floor(placeNearCell.columns / 2) + result.columns * placeNearCell.rows; - hasGaps = true; } } @@ -484,21 +515,19 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { if (item.largeBaseSize) { // Cycle the tile size once to set up the tile with its larger base size // This also fills any gaps in the grid, hence no extra call to fillGaps - result = cycleTileSize(item.id, result); - } else if (hasGaps) { - result = fillGaps(result); + result = cycleTileSize(result, item); } } return result; } -const largeTileDimensions = (g: Grid): [number, number] => [ +const largeTileDimensions = (g: BigGridState): [number, number] => [ Math.min(3, Math.max(2, g.columns - 1)), 2, ]; -const extraLargeTileDimensions = (g: Grid): [number, number] => +const extraLargeTileDimensions = (g: BigGridState): [number, number] => g.columns > 3 ? [4, 3] : [g.columns, 2]; /** @@ -507,8 +536,11 @@ const extraLargeTileDimensions = (g: Grid): [number, number] => * @param g The grid. * @returns The updated grid. */ -export function cycleTileSize(tileId: string, g: Grid): Grid { - const from = g.cells.findIndex((c) => c?.item.id === tileId); +export function cycleTileSize( + g: BigGridState, + tile: TileDescriptor +): BigGridState { + const from = g.cells.findIndex((c) => c?.item === tile); if (from === -1) return g; // Tile removed, no change const fromCell = g.cells[from]!; const fromWidth = fromCell.columns; @@ -629,8 +661,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { /** * Resizes the grid to a new column width. */ -export function resize(g: Grid, columns: number): Grid { - const result: Grid = { columns, cells: [] }; +export function resize(g: BigGridState, columns: number): BigGridState { + const result: BigGridState = { columns, cells: [] }; const [largeColumns, largeRows] = largeTileDimensions(result); // Copy each tile from the old grid to the resized one in the same order @@ -640,6 +672,7 @@ export function resize(g: Grid, columns: number): Grid { for (const cell of g.cells) { if (cell?.origin) { + // TODO make aware of extra large tiles const [nextColumns, nextRows] = cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1]; @@ -672,7 +705,7 @@ export function resize(g: Grid, columns: number): Grid { /** * Promotes speakers to the first page of the grid. */ -export function promoteSpeakers(g: Grid) { +export function promoteSpeakers(g: BigGridState) { // This is all a bit of a hack right now, because we don't know if the designs // will stick with this approach in the long run // We assume that 4 rows are probably about 1 page @@ -694,10 +727,149 @@ export function promoteSpeakers(g: Grid) { toCell === undefined || (toCell.columns === 1 && toCell.rows === 1) ) { - moveTile(g, from, to); + moveTileUnchecked(g, from, to); break; } } } } } + +/** + * The algorithm for updating a grid with a new set of tiles. + */ +function updateTiles( + g: BigGridState, + tiles: TileDescriptor[] +): BigGridState { + // Step 1: Update tiles that still exist, and remove tiles that have left + // the grid + const itemsById = new Map(tiles.map((i) => [i.id, i])); + const grid1: BigGridState = { + ...g, + cells: g.cells.map((c) => { + if (c === undefined) return undefined; + const item = itemsById.get(c.item.id); + return item === undefined ? undefined : { ...c, item }; + }), + }; + + // Step 2: Add new tiles + const existingItemIds = new Set( + grid1.cells.filter((c) => c !== undefined).map((c) => c!.item.id) + ); + const newItems = tiles.filter((i) => !existingItemIds.has(i.id)); + const grid2 = addItems(newItems, grid1); + + // Step 3: Promote speakers to the top + promoteSpeakers(grid2); + + return fillGaps(grid2); +} + +function updateBounds(g: BigGridState, bounds: RectReadOnly): BigGridState { + const columns = Math.max(2, Math.floor(bounds.width * 0.0045)); + return columns === g.columns ? g : resize(g, columns); +} + +const Slots: FC<{ s: BigGridState }> = memo(({ s: g }) => { + const areas = new Array<(number | null)[]>( + Math.ceil(g.cells.length / g.columns) + ); + for (let i = 0; i < areas.length; i++) + areas[i] = new Array(g.columns).fill(null); + + let slotCount = 0; + for (let i = 0; i < g.cells.length; i++) { + const cell = g.cells[i]; + if (cell?.origin) { + const slotEnd = i + cell.columns - 1 + g.columns * (cell.rows - 1); + forEachCellInArea( + i, + slotEnd, + g, + (_c, j) => (areas[row(j, g)][column(j, g)] = slotCount) + ); + slotCount++; + } + } + + const style = { + gridTemplateAreas: areas + .map( + (row) => + `'${row + .map((slotId) => (slotId === null ? "." : `s${slotId}`)) + .join(" ")}'` + ) + .join(" "), + gridTemplateColumns: `repeat(${g.columns}, 1fr)`, + }; + + const slots = new Array(slotCount); + for (let i = 0; i < slotCount; i++) + slots[i] = ; + + return ( +
+ {slots} +
+ ); +}); + +/** + * Given a tile and numbers in the range [0, 1) describing a position within the + * tile, this returns the index of the specific cell in which that position + * lies. + */ +function positionOnTileToCell( + g: BigGridState, + tileOriginIndex: number, + xPositionOnTile: number, + yPositionOnTile: number +): number { + const tileOrigin = g.cells[tileOriginIndex]!; + const columnOnTile = Math.floor(xPositionOnTile * tileOrigin.columns); + const rowOnTile = Math.floor(yPositionOnTile * tileOrigin.rows); + return tileOriginIndex + columnOnTile + g.columns * rowOnTile; +} + +function dragTile( + g: BigGridState, + from: TileDescriptor, + to: TileDescriptor, + xPositionOnFrom: number, + yPositionOnFrom: number, + xPositionOnTo: number, + yPositionOnTo: number +): BigGridState { + const fromOrigin = g.cells.findIndex((c) => c?.item === from); + const toOrigin = g.cells.findIndex((c) => c?.item === to); + const fromCell = positionOnTileToCell( + g, + fromOrigin, + xPositionOnFrom, + yPositionOnFrom + ); + const toCell = positionOnTileToCell( + g, + toOrigin, + xPositionOnTo, + yPositionOnTo + ); + + return moveTile(g, fromOrigin, fromOrigin + toCell - fromCell); +} + +export const BigGrid: Layout = { + emptyState: { columns: 4, cells: [] }, + updateTiles, + updateBounds, + getTiles: (g) => + g.cells.filter((c) => c?.origin).map((c) => c!.item as T), + canDragTile: () => true, + dragTile, + toggleFocus: cycleTileSize, + Slots, + rememberState: false, +}; diff --git a/src/video-grid/Layout.tsx b/src/video-grid/Layout.tsx new file mode 100644 index 0000000..2b29594 --- /dev/null +++ b/src/video-grid/Layout.tsx @@ -0,0 +1,178 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ComponentType, useCallback, useMemo, useRef } from "react"; + +import type { RectReadOnly } from "react-use-measure"; +import { useReactiveState } from "../useReactiveState"; +import type { TileDescriptor } from "./VideoGrid"; + +/** + * A video grid layout system with concrete states of type State. + */ +// Ideally State would be parameterized by the tile data type, but then that +// makes Layout a higher-kinded type, which isn't achievable in TypeScript +// (unless you invoke some dark type-level computation magic… 😏) +// So we're stuck with these types being a little too strong. +export interface Layout { + /** + * The layout state for zero tiles. + */ + readonly emptyState: State; + /** + * Updates/adds/removes tiles in a way that looks natural in the context of + * the given initial state. + */ + readonly updateTiles: (s: State, tiles: TileDescriptor[]) => State; + /** + * Adapts the layout to a new container size. + */ + readonly updateBounds: (s: State, bounds: RectReadOnly) => State; + /** + * Gets tiles in the order created by the layout. + */ + readonly getTiles: (s: State) => TileDescriptor[]; + /** + * Determines whether a tile is draggable. + */ + readonly canDragTile: (s: State, tile: TileDescriptor) => boolean; + /** + * Drags the tile 'from' to the location of the tile 'to' (if possible). + * The position parameters are numbers in the range [0, 1) describing the + * specific positions on 'from' and 'to' that the drag gesture is targeting. + */ + readonly dragTile: ( + s: State, + from: TileDescriptor, + to: TileDescriptor, + xPositionOnFrom: number, + yPositionOnFrom: number, + xPositionOnTo: number, + yPositionOnTo: number + ) => State; + /** + * Toggles the focus of the given tile (if this layout has the concept of + * focus). + */ + readonly toggleFocus?: (s: State, tile: TileDescriptor) => State; + /** + * A React component generating the slot elements for a given layout state. + */ + readonly Slots: ComponentType<{ s: State }>; + /** + * Whether the state of this layout should be remembered even while a + * different layout is active. + */ + readonly rememberState: boolean; +} + +/** + * A version of Map with stronger types that allow us to save layout states in a + * type-safe way. + */ +export interface LayoutStatesMap { + get(layout: Layout): State | undefined; + set(layout: Layout, state: State): LayoutStatesMap; + delete(layout: Layout): boolean; +} + +/** + * Hook creating a Map to store layout states in. + */ +export const useLayoutStates = (): LayoutStatesMap => { + const layoutStates = useRef>(); + if (layoutStates.current === undefined) layoutStates.current = new Map(); + return layoutStates.current as LayoutStatesMap; +}; + +/** + * Hook which uses the provided layout system to arrange a set of items into a + * concrete layout state, and provides callbacks for user interaction. + */ +export const useLayout = ( + layout: Layout, + items: TileDescriptor[], + bounds: RectReadOnly, + layoutStates: LayoutStatesMap +) => { + const prevLayout = useRef>(); + const prevState = layoutStates.get(layout); + + const [state, setState] = useReactiveState(() => { + // If the bounds aren't known yet, don't add anything to the layout + if (bounds.width === 0) { + return layout.emptyState; + } else { + if ( + prevLayout.current !== undefined && + layout !== prevLayout.current && + !prevLayout.current.rememberState + ) + layoutStates.delete(prevLayout.current); + + const baseState = layoutStates.get(layout) ?? layout.emptyState; + return layout.updateTiles(layout.updateBounds(baseState, bounds), items); + } + }, [layout, items, bounds]); + + const generation = useRef(0); + if (state !== prevState) generation.current++; + + prevLayout.current = layout as Layout; + // No point in remembering an empty state, plus it would end up clobbering the + // real saved state while restoring a layout + if (state !== layout.emptyState) layoutStates.set(layout, state); + + return { + state, + orderedItems: useMemo(() => layout.getTiles(state), [layout, state]), + generation: generation.current, + canDragTile: useCallback( + (tile: TileDescriptor) => layout.canDragTile(state, tile), + [layout, state] + ), + dragTile: useCallback( + ( + from: TileDescriptor, + to: TileDescriptor, + xPositionOnFrom: number, + yPositionOnFrom: number, + xPositionOnTo: number, + yPositionOnTo: number + ) => + setState((s) => + layout.dragTile( + s, + from, + to, + xPositionOnFrom, + yPositionOnFrom, + xPositionOnTo, + yPositionOnTo + ) + ), + [layout, setState] + ), + toggleFocus: useMemo( + () => + layout.toggleFocus && + ((tile: TileDescriptor) => + setState((s) => layout.toggleFocus!(s, tile))), + [layout, setState] + ), + slots: , + }; +}; diff --git a/src/video-grid/NewVideoGrid.module.css b/src/video-grid/NewVideoGrid.module.css index 7e34a2d..c822b41 100644 --- a/src/video-grid/NewVideoGrid.module.css +++ b/src/video-grid/NewVideoGrid.module.css @@ -23,11 +23,8 @@ limitations under the License. overflow-x: hidden; } -.slotGrid { +.slots { position: relative; - display: grid; - grid-auto-rows: 163px; - gap: 8px; } .slot { @@ -38,10 +35,4 @@ limitations under the License. .grid { padding: 0 22px var(--footerHeight); } - - .slotGrid { - grid-auto-rows: 183px; - column-gap: 18px; - row-gap: 21px; - } } diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 754b029..b88128d 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -17,17 +17,16 @@ limitations under the License. import { SpringRef, TransitionFn, useTransition } from "@react-spring/web"; import { EventTypes, Handler, useScroll } from "@use-gesture/react"; import React, { - Dispatch, + CSSProperties, + FC, ReactNode, - SetStateAction, - useCallback, useEffect, useMemo, useRef, useState, } from "react"; import useMeasure from "react-use-measure"; -import { zipWith } from "lodash"; +import { zip } from "lodash"; import styles from "./NewVideoGrid.module.css"; import { @@ -38,99 +37,9 @@ import { } from "./VideoGrid"; import { useReactiveState } from "../useReactiveState"; import { useMergedRefs } from "../useMergedRefs"; -import { - Grid, - Cell, - row, - column, - fillGaps, - forEachCellInArea, - cycleTileSize, - addItems, - tryMoveTile, - resize, - promoteSpeakers, -} from "./model"; import { TileWrapper } from "./TileWrapper"; - -interface GridState extends Grid { - /** - * The ID of the current state of the grid. - */ - generation: number; -} - -const useGridState = ( - columns: number | null, - items: TileDescriptor[] -): [GridState | null, Dispatch>] => { - const [grid, setGrid_] = useReactiveState( - (prevGrid = null) => { - if (prevGrid === null) { - // We can't do anything if the column count isn't known yet - if (columns === null) { - return null; - } else { - prevGrid = { generation: 0, columns, cells: [] }; - } - } - - // Step 1: Update tiles that still exist, and remove tiles that have left - // the grid - const itemsById = new Map(items.map((i) => [i.id, i])); - const grid1: Grid = { - ...prevGrid, - cells: prevGrid.cells.map((c) => { - if (c === undefined) return undefined; - const item = itemsById.get(c.item.id); - return item === undefined ? undefined : { ...c, item }; - }), - }; - - // Step 2: Resize the grid if necessary and backfill gaps left behind by - // removed tiles - // Resizing already takes care of backfilling gaps - const grid2 = - columns !== grid1.columns ? resize(grid1, columns!) : fillGaps(grid1); - - // Step 3: Add new tiles to the end of the grid - const existingItemIds = new Set( - grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id) - ); - const newItems = items.filter((i) => !existingItemIds.has(i.id)); - const grid3 = addItems(newItems, grid2); - - // Step 4: Promote speakers to the top - promoteSpeakers(grid3); - - return { ...grid3, generation: prevGrid.generation + 1 }; - }, - [columns, items] - ); - - const setGrid: Dispatch> = useCallback( - (action) => { - if (typeof action === "function") { - setGrid_((prevGrid) => - prevGrid === null - ? null - : { - ...(action as (prev: Grid) => Grid)(prevGrid), - generation: prevGrid.generation + 1, - } - ); - } else { - setGrid_((prevGrid) => ({ - ...action, - generation: prevGrid?.generation ?? 1, - })); - } - }, - [setGrid_] - ); - - return [grid, setGrid]; -}; +import { BigGrid } from "./BigGrid"; +import { useLayout } from "./Layout"; interface Rect { x: number; @@ -139,8 +48,8 @@ interface Rect { height: number; } -interface Tile extends Rect { - item: TileDescriptor; +interface Tile extends Rect { + item: TileDescriptor; } interface DragState { @@ -151,12 +60,21 @@ interface DragState { cursorY: number; } +interface SlotProps { + style?: CSSProperties; +} + +export const Slot: FC = ({ style }) => ( +
+); + /** * An interactive, animated grid of video tiles. */ export function NewVideoGrid({ items, disableAnimations, + layoutStates, children, }: Props) { // Overview: This component lays out tiles by rendering an invisible template @@ -169,36 +87,36 @@ export function NewVideoGrid({ // most recently rendered generation of the grid, and watch it with a // MutationObserver. - const [slotGrid, setSlotGrid] = useState(null); - const [slotGridGeneration, setSlotGridGeneration] = useState(0); + const [slotsRoot, setSlotsRoot] = useState(null); + const [renderedGeneration, setRenderedGeneration] = useState(0); useEffect(() => { - if (slotGrid !== null) { - setSlotGridGeneration( - parseInt(slotGrid.getAttribute("data-generation")!) + if (slotsRoot !== null) { + setRenderedGeneration( + parseInt(slotsRoot.getAttribute("data-generation")!) ); const observer = new MutationObserver((mutations) => { if (mutations.some((m) => m.type === "attributes")) { - setSlotGridGeneration( - parseInt(slotGrid.getAttribute("data-generation")!) + setRenderedGeneration( + parseInt(slotsRoot.getAttribute("data-generation")!) ); } }); - observer.observe(slotGrid, { attributes: true }); + observer.observe(slotsRoot, { attributes: true }); return () => observer.disconnect(); } - }, [slotGrid, setSlotGridGeneration]); + }, [slotsRoot, setRenderedGeneration]); const [gridRef1, gridBounds] = useMeasure(); const gridRef2 = useRef(null); const gridRef = useMergedRefs(gridRef1, gridRef2); const slotRects = useMemo(() => { - if (slotGrid === null) return []; + if (slotsRoot === null) return []; - const slots = slotGrid.getElementsByClassName(styles.slot); + const slots = slotsRoot.getElementsByClassName(styles.slot); const rects = new Array(slots.length); for (let i = 0; i < slots.length; i++) { const slot = slots[i] as HTMLElement; @@ -214,32 +132,34 @@ export function NewVideoGrid({ // The rects may change due to the grid being resized or rerendered, but // eslint can't statically verify this // eslint-disable-next-line react-hooks/exhaustive-deps - }, [slotGrid, slotGridGeneration, gridBounds]); + }, [slotsRoot, renderedGeneration, gridBounds]); - const columns = useMemo( - () => - // The grid bounds might not be known yet - gridBounds.width === 0 - ? null - : Math.max(2, Math.floor(gridBounds.width * 0.0045)), - [gridBounds] - ); + // TODO: Implement more layouts and select the right one here + const layout = BigGrid; + const { + state: grid, + orderedItems, + generation, + canDragTile, + dragTile, + toggleFocus, + slots, + } = useLayout(layout, items, gridBounds, layoutStates); - const [grid, setGrid] = useGridState(columns, items); - - const [tiles] = useReactiveState( + const [tiles] = useReactiveState[]>( (prevTiles) => { // If React hasn't yet rendered the current generation of the grid, skip // the update, because grid and slotRects will be out of sync - if (slotGridGeneration !== grid?.generation) return prevTiles ?? []; + if (renderedGeneration !== generation) return prevTiles ?? []; - const tileCells = grid.cells.filter((c) => c?.origin) as Cell[]; - const tileRects = new Map, Rect>( - zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect]) + const tileRects = new Map( + zip(orderedItems, slotRects) as [TileDescriptor, Rect][] ); + // In order to not break drag gestures, it's critical that we render tiles + // in a stable order (that of 'items') return items.map((item) => ({ ...tileRects.get(item)!, item })); }, - [slotRects, grid, slotGridGeneration] + [slotRects, grid, renderedGeneration] ); // Drag state is stored in a ref rather than component state, because we use @@ -249,8 +169,8 @@ export function NewVideoGrid({ const [tileTransitions, springRef] = useTransition( tiles, () => ({ - key: ({ item }: Tile) => item.id, - from: ({ x, y, width, height }: Tile) => ({ + key: ({ item }: Tile) => item.id, + from: ({ x, y, width, height }: Tile) => ({ opacity: 0, scale: 0, shadow: 1, @@ -263,7 +183,7 @@ export function NewVideoGrid({ immediate: disableAnimations, }), enter: { opacity: 1, scale: 1, immediate: disableAnimations }, - update: ({ item, x, y, width, height }: Tile) => + update: ({ item, x, y, width, height }: Tile) => item.id === dragState.current?.tileId ? null : { @@ -277,7 +197,7 @@ export function NewVideoGrid({ config: { mass: 0.7, tension: 252, friction: 25 }, }) // react-spring's types are bugged and can't infer the spring type - ) as unknown as [TransitionFn, SpringRef]; + ) as unknown as [TransitionFn, TileSpring>, SpringRef]; // Because we're using react-spring in imperative mode, we're responsible for // firing animations manually whenever the tiles array updates @@ -288,11 +208,9 @@ export function NewVideoGrid({ const animateDraggedTile = (endOfGesture: boolean) => { const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!; const tile = tiles.find((t) => t.item.id === tileId)!; - const originIndex = grid!.cells.findIndex((c) => c?.item.id === tileId); - const originCell = grid!.cells[originIndex]!; springRef.current - .find((c) => (c.item as Tile).item.id === tileId) + .find((c) => (c.item as Tile).item.id === tileId) ?.start( endOfGesture ? { @@ -320,36 +238,23 @@ export function NewVideoGrid({ } ); - const columns = grid!.columns; - const rows = row(grid!.cells.length - 1, grid!) + 1; - - const cursorColumn = Math.floor( - (cursorX / slotGrid!.clientWidth) * columns - ); - const cursorRow = Math.floor((cursorY / slotGrid!.clientHeight) * rows); - - const cursorColumnOnTile = Math.floor( - ((cursorX - tileX) / tile.width) * originCell.columns - ); - const cursorRowOnTile = Math.floor( - ((cursorY - tileY) / tile.height) * originCell.rows + const overTile = tiles.find( + (t) => + cursorX >= t.x && + cursorX < t.x + t.width && + cursorY >= t.y && + cursorY < t.y + t.height ); - const dest = - Math.max( - 0, - Math.min( - columns - originCell.columns, - cursorColumn - cursorColumnOnTile - ) - ) + - grid!.columns * - Math.max( - 0, - Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile) - ); - - if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest)); + if (overTile !== undefined) + dragTile( + tile.item, + overTile.item, + (cursorX - tileX) / tile.width, + (cursorY - tileY) / tile.height, + (cursorX - overTile.x) / overTile.width, + (cursorY - overTile.y) / overTile.height + ); }; // Callback for useDrag. We could call useDrag here, but the default @@ -367,29 +272,33 @@ export function NewVideoGrid({ }: Parameters>[0] ) => { if (tap) { - setGrid((g) => cycleTileSize(tileId, g!)); + toggleFocus?.(items.find((i) => i.id === tileId)!); } else { - const tileSpring = springRef.current - .find((c) => (c.item as Tile).item.id === tileId)! - .get(); + const tileController = springRef.current.find( + (c) => (c.item as Tile).item.id === tileId + )!; - if (dragState.current === null) { - dragState.current = { - tileId, - tileX: tileSpring.x, - tileY: tileSpring.y, - cursorX: initialX - gridBounds.x, - cursorY: initialY - gridBounds.y + scrollOffset.current, - }; + if (canDragTile((tileController.item as Tile).item)) { + if (dragState.current === null) { + const tileSpring = tileController.get(); + dragState.current = { + tileId, + tileX: tileSpring.x, + tileY: tileSpring.y, + cursorX: initialX - gridBounds.x, + cursorY: initialY - gridBounds.y + scrollOffset.current, + }; + } + + dragState.current.tileX += dx; + dragState.current.tileY += dy; + dragState.current.cursorX += dx; + dragState.current.cursorY += dy; + + animateDraggedTile(last); + + if (last) dragState.current = null; } - dragState.current.tileX += dx; - dragState.current.tileY += dy; - dragState.current.cursorX += dx; - dragState.current.cursorY += dy; - - animateDraggedTile(last); - - if (last) dragState.current = null; } }; @@ -411,52 +320,6 @@ export function NewVideoGrid({ { target: gridRef2 } ); - const slotGridStyle = useMemo(() => { - if (grid === null) return {}; - - const areas = new Array<(number | null)[]>( - Math.ceil(grid.cells.length / grid.columns) - ); - for (let i = 0; i < areas.length; i++) - areas[i] = new Array(grid.columns).fill(null); - - let slotId = 0; - for (let i = 0; i < grid.cells.length; i++) { - const cell = grid.cells[i]; - if (cell?.origin) { - const slotEnd = i + cell.columns - 1 + grid.columns * (cell.rows - 1); - forEachCellInArea( - i, - slotEnd, - grid, - (_c, j) => (areas[row(j, grid)][column(j, grid)] = slotId) - ); - slotId++; - } - } - - return { - gridTemplateAreas: areas - .map( - (row) => - `'${row - .map((slotId) => (slotId === null ? "." : `s${slotId}`)) - .join(" ")}'` - ) - .join(" "), - gridTemplateColumns: `repeat(${columns}, 1fr)`, - }; - }, [grid, columns]); - - const slots = useMemo(() => { - const slots = new Array(items.length); - for (let i = 0; i < items.length; i++) - slots[i] = ( -
- ); - return slots; - }, [items.length]); - // Render nothing if the grid has yet to be generated if (grid === null) { return
; @@ -465,10 +328,9 @@ export function NewVideoGrid({ return (
{slots}
@@ -482,7 +344,7 @@ export function NewVideoGrid({ data={tile.item.data} {...spring} > - {children as (props: ChildrenProperties) => ReactNode} + {children as (props: ChildrenProperties) => ReactNode} ))}
diff --git a/src/video-grid/TileWrapper.tsx b/src/video-grid/TileWrapper.tsx index b9f84b5..09b67aa 100644 --- a/src/video-grid/TileWrapper.tsx +++ b/src/video-grid/TileWrapper.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, memo, ReactNode, RefObject, useRef } from "react"; +import React, { memo, ReactNode, RefObject, useRef } from "react"; import { EventTypes, Handler, useDrag } from "@use-gesture/react"; import { SpringValue, to } from "@react-spring/web"; @@ -47,7 +47,7 @@ interface Props { * A wrapper around a tile in a video grid. This component exists to decouple * child components from the grid. */ -export const TileWrapper: FC> = memo( +export const TileWrapper = memo( ({ id, onDragRef, @@ -97,4 +97,7 @@ export const TileWrapper: FC> = memo( ); } -); + // We pretend this component is a simple function rather than a + // NamedExoticComponent, because that's the only way we can fit in a type + // parameter +) as (props: Props) => JSX.Element; diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index d87f58b..a9b847b 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -42,6 +42,7 @@ import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer" import styles from "./VideoGrid.module.css"; import { Layout } from "../room/GridLayoutMenu"; import { TileWrapper } from "./TileWrapper"; +import { LayoutStatesMap } from "./Layout"; interface TilePosition { x: number; @@ -817,6 +818,7 @@ export interface VideoGridProps { items: TileDescriptor[]; layout: Layout; disableAnimations: boolean; + layoutStates: LayoutStatesMap; children: (props: ChildrenProperties) => React.ReactNode; } diff --git a/src/video-grid/VideoTile.tsx b/src/video-grid/VideoTile.tsx index 81a2361..1cf77d3 100644 --- a/src/video-grid/VideoTile.tsx +++ b/src/video-grid/VideoTile.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useCallback } from "react"; import { animated } from "@react-spring/web"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; @@ -34,8 +34,10 @@ import styles from "./VideoTile.module.css"; import { ReactComponent as MicIcon } from "../icons/Mic.svg"; import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg"; import { useReactiveState } from "../useReactiveState"; +import { FullscreenButton } from "../button/Button"; export interface ItemData { + id: string; member?: RoomMember; sfuParticipant: LocalParticipant | RemoteParticipant; content: TileContent; @@ -48,7 +50,9 @@ export enum TileContent { interface Props { data: ItemData; - + maximised: boolean; + fullscreen: boolean; + onToggleFullscreen: (itemId: string) => void; // TODO: Refactor these props. targetWidth: number; targetHeight: number; @@ -62,6 +66,9 @@ export const VideoTile = React.forwardRef( ( { data, + maximised, + fullscreen, + onToggleFullscreen, className, style, targetWidth, @@ -93,17 +100,33 @@ export const VideoTile = React.forwardRef( } }, [member, setDisplayName]); - const audioEl = React.useRef(null); const { isMuted: microphoneMuted } = useMediaTrack( content === TileContent.UserMedia ? Track.Source.Microphone : Track.Source.ScreenShareAudio, - sfuParticipant, - { - element: audioEl, - } + sfuParticipant ); + const onFullscreen = useCallback(() => { + onToggleFullscreen(data.id); + }, [data, onToggleFullscreen]); + + const toolbarButtons: JSX.Element[] = []; + if (!sfuParticipant.isLocal) { + // TODO local volume option, which would also go here + + if (content === TileContent.ScreenShare) { + toolbarButtons.push( + + ); + } + } + // Firefox doesn't respect the disablePictureInPicture attribute // https://bugzilla.mozilla.org/show_bug.cgi?id=1611831 @@ -117,11 +140,15 @@ export const VideoTile = React.forwardRef( showSpeakingIndicator, [styles.muted]: microphoneMuted, [styles.screenshare]: content === TileContent.ScreenShare, + [styles.maximised]: maximised, })} style={style} ref={tileRef} data-testid="videoTile" > + {toolbarButtons.length > 0 && (!maximised || fullscreen) && ( +
{toolbarButtons}
+ )} {content === TileContent.UserMedia && !sfuParticipant.isCameraEnabled && ( <>
@@ -134,7 +161,7 @@ export const VideoTile = React.forwardRef( /> )} - {content == TileContent.ScreenShare ? ( + {content === TileContent.ScreenShare ? (
{t("{{displayName}} is presenting", { displayName })}
@@ -155,7 +182,6 @@ export const VideoTile = React.forwardRef( : Track.Source.ScreenShare } /> -