Merge remote-tracking branch 'origin/livekit' into dbkr/openid

This commit is contained in:
David Baker 2023-06-28 16:40:59 +01:00
commit a0b342069d
30 changed files with 771 additions and 373 deletions

View file

@ -2,7 +2,7 @@ name: Build
on: on:
pull_request: {} pull_request: {}
push: push:
branches: [livekit] branches: [livekit, full-mesh]
jobs: jobs:
build: build:
name: Build name: Build

View file

@ -1,6 +1,8 @@
name: Run jest tests name: Run jest tests
on: on:
pull_request: {} pull_request: {}
push:
branches: [livekit, full-mesh]
jobs: jobs:
jest: jest:
name: Run jest tests name: Run jest tests
@ -16,3 +18,7 @@ jobs:
run: "yarn install" run: "yarn install"
- name: Jest - name: Jest
run: "yarn run test" run: "yarn run test"
- name: Upload to codecov
uses: codecov/codecov-action@v3
with:
flags: unittests

View file

@ -122,6 +122,11 @@
"\\.(css|less|svg)+$": "identity-obj-proxy", "\\.(css|less|svg)+$": "identity-obj-proxy",
"^\\./IndexedDBWorker\\?worker$": "<rootDir>/test/mocks/workerMock.ts", "^\\./IndexedDBWorker\\?worker$": "<rootDir>/test/mocks/workerMock.ts",
"^\\./olm$": "<rootDir>/test/mocks/olmMock.ts" "^\\./olm$": "<rootDir>/test/mocks/olmMock.ts"
} },
"collectCoverage": true,
"coverageReporters": [
"text",
"cobertura"
]
} }
} }

View file

@ -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>": "Други потребители се опитват да се присъединят в разговора от несъвместими версии. Следните потребители трябва да проверят дали са презаредили браузърите си<1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Други потребители се опитват да се присъединят в разговора от несъвместими версии. Следните потребители трябва да проверят дали са презаредили браузърите си<1>{userLis}</1>",
"Password": "Парола", "Password": "Парола",
"Passwords must match": "Паролите не съвпадат", "Passwords must match": "Паролите не съвпадат",
"Press and hold to talk over {{name}}": "Натиснете и задръжте за да говорите заедно с {{name}}",
"Profile": "Профил", "Profile": "Профил",
"Recaptcha dismissed": "Recaptcha отхвърлена", "Recaptcha dismissed": "Recaptcha отхвърлена",
"Recaptcha not loaded": "Recaptcha не е заредена", "Recaptcha not loaded": "Recaptcha не е заредена",

View file

@ -62,7 +62,6 @@
"Inspector": "Insepktor", "Inspector": "Insepktor",
"Incompatible versions!": "Nekompatibilní verze!", "Incompatible versions!": "Nekompatibilní verze!",
"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</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "Tato stárnka je chráněna pomocí ReCAPTCHA a Google <2>zásad ochrany osobních údajů</2> a <6>podmínky služby</6> platí.<9></9>Kliknutím na \"Registrovat\", souhlasíte s <12>Pravidly a podmínkami</12>", "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "Tato stárnka je chráněna pomocí ReCAPTCHA a Google <2>zásad ochrany osobních údajů</2> a <6>podmínky služby</6> platí.<9></9>Kliknutím na \"Registrovat\", souhlasíte s <12>Pravidly a podmínkami</12>",
"Walkie-talkie call name": "Jméno vysílačkového hovoru", "Walkie-talkie call name": "Jméno vysílačkového hovoru",
"Walkie-talkie call": "Vysílačkový hovor", "Walkie-talkie call": "Vysílačkový hovor",

View file

@ -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}</1>": "Andere Benutzer versuchen, diesem Aufruf von einer inkompatiblen Softwareversion aus beizutreten. Diese Benutzer sollten ihre Web-Browser Seite neu laden:<1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Andere Benutzer versuchen, diesem Aufruf von einer inkompatiblen Softwareversion aus beizutreten. Diese Benutzer sollten ihre Web-Browser Seite neu laden:<1>{userLis}</1>",
"Password": "Passwort", "Password": "Passwort",
"Passwords must match": "Passwörter müssen übereinstimmen", "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", "Profile": "Profil",
"Recaptcha dismissed": "Recaptcha abgelehnt", "Recaptcha dismissed": "Recaptcha abgelehnt",
"Recaptcha not loaded": "Recaptcha nicht geladen", "Recaptcha not loaded": "Recaptcha nicht geladen",

View file

@ -71,6 +71,5 @@
"Close": "Κλείσιμο", "Close": "Κλείσιμο",
"Change layout": "Αλλαγή διάταξης", "Change layout": "Αλλαγή διάταξης",
"Camera": "Κάμερα", "Camera": "Κάμερα",
"Audio": "Ήχος", "Audio": "Ήχος"
"{{name}} (Connecting...)": "{{name}} (Συνδέεται...)"
} }

View file

@ -94,7 +94,6 @@
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Crear una cuenta</0> o <2>Acceder como invitado</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Crear una cuenta</0> o <2>Acceder como invitado</2>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Unirse ahora</0><1>Or</1><2>Copiar el enlace y unirse más tarde</2>", "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Unirse ahora</0><1>Or</1><2>Copiar el enlace y unirse más tarde</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>¿Ya tienes una cuenta?</0><1><0>Iniciar sesión</0> o <2>Acceder como invitado</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>¿Ya tienes una cuenta?</0><1><0>Iniciar sesión</0> o <2>Acceder como invitado</2></1>",
"{{name}} (Connecting...)": "{{name}} (Conectando...)",
"Element Call Home": "Inicio de Element Call", "Element Call Home": "Inicio de Element Call",
"Copy": "Copiar", "Copy": "Copiar",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.</0>", "<0>Submitting debug logs will help us track down the problem.</0>": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.</0>",

View file

@ -4,7 +4,6 @@
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Loo konto</0> Või <2>Sisene külalisena</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Loo konto</0> Või <2>Sisene külalisena</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>On sul juba konto?</0><1><0>Logi sisse</0> Või <2>Logi sisse külalisena</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>On sul juba konto?</0><1><0>Logi sisse</0> Või <2>Logi sisse külalisena</2></1>",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",
"{{count}} people connected|other": "{{count}} osalejat liitunud",
"Invite people": "Kutsu inimesi", "Invite people": "Kutsu inimesi",
"Invite": "Kutsu", "Invite": "Kutsu",
"Inspector": "Inspektor", "Inspector": "Inspektor",
@ -114,5 +113,7 @@
"How did it go?": "Kuidas sujus?", "How did it go?": "Kuidas sujus?",
"{{displayName}}, your call has ended.": "{{displayName}}, sinu kõne on lõppenud.", "{{displayName}}, your call has ended.": "{{displayName}}, sinu kõne on lõppenud.",
"<0>Thanks for your feedback!</0>": "<0>Täname Sind tagasiside eest!</0>", "<0>Thanks for your feedback!</0>": "<0>Täname Sind tagasiside eest!</0>",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Meie rakenduse paremaks muutmiseks me hea meelega ootame Sinu arvamusi.</0>" "<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Meie rakenduse paremaks muutmiseks me hea meelega ootame Sinu arvamusi.</0>",
"Show connection stats": "Näita ühenduse statistikat",
"{{displayName}} is presenting": "{{displayName}} on esitlemas"
} }

View file

@ -54,7 +54,6 @@
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>چرا یک رمز عبور برای حساب کاربری خود تنظیم نمی‌کنید؟</0><1>شما می‌توانید نام خود را حفظ کنید و یک آواتار برای تماس‌های آینده بسازید</1>", "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>چرا یک رمز عبور برای حساب کاربری خود تنظیم نمی‌کنید؟</0><1>شما می‌توانید نام خود را حفظ کنید و یک آواتار برای تماس‌های آینده بسازید</1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>ساخت حساب کاربری</0> Or <2>دسترسی به عنوان میهمان</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>ساخت حساب کاربری</0> Or <2>دسترسی به عنوان میهمان</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>از قبل حساب کاربری دارید؟</0><1><0>ورود</0> Or <2>به عنوان یک میهمان وارد شوید</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>از قبل حساب کاربری دارید؟</0><1><0>ورود</0> Or <2>به عنوان یک میهمان وارد شوید</2></1>",
"{{count}} people connected|other": "{{count}} نفر متصل هستند",
"Local volume": "حجم داخلی", "Local volume": "حجم داخلی",
"Inspector": "بازرس", "Inspector": "بازرس",
"Incompatible versions!": "نسخه‌های ناسازگار!", "Incompatible versions!": "نسخه‌های ناسازگار!",
@ -72,7 +71,6 @@
"Register": "ثبت‌نام", "Register": "ثبت‌نام",
"Recaptcha not loaded": "کپچا بارگیری نشد", "Recaptcha not loaded": "کپچا بارگیری نشد",
"Recaptcha dismissed": "ریکپچا رد شد", "Recaptcha dismissed": "ریکپچا رد شد",
"Press and hold spacebar to talk": "برای صحبت کردن کلید فاصله را فشار داده و نگه دارید",
"Passwords must match": "رمز عبور باید همخوانی داشته باشد", "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>": "کاربران دیگر تلاش می‌کنند با ورژن‌های ناسازگار به مکالمه بپیوندند. این کاربران باید از بروزرسانی مرورگرشان اطمینان داشته باشند:<1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "کاربران دیگر تلاش می‌کنند با ورژن‌های ناسازگار به مکالمه بپیوندند. این کاربران باید از بروزرسانی مرورگرشان اطمینان داشته باشند:<1>{userLis}</1>",
"Not registered yet? <2>Create an account</2>": "هنوز ثبت‌نام نکرده‌اید؟ <2>ساخت حساب کاربری</2>", "Not registered yet? <2>Create an account</2>": "هنوز ثبت‌نام نکرده‌اید؟ <2>ساخت حساب کاربری</2>",

View file

@ -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}</1>": "Des utilisateurs essayent de rejoindre cet appel à partir de versions incompatibles. Ces utilisateurs doivent rafraîchir la page dans leur navigateur : <1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Des utilisateurs essayent de rejoindre cet appel à partir de versions incompatibles. Ces utilisateurs doivent rafraîchir la page dans leur navigateur : <1>{userLis}</1>",
"Password": "Mot de passe", "Password": "Mot de passe",
"Passwords must match": "Les mots de passe doivent correspondre", "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", "Profile": "Profil",
"Recaptcha dismissed": "Recaptcha refusé", "Recaptcha dismissed": "Recaptcha refusé",
"Recaptcha not loaded": "Recaptcha non chargé", "Recaptcha not loaded": "Recaptcha non chargé",
@ -114,5 +113,7 @@
"{{count}} stars|one": "{{count}} favori", "{{count}} stars|one": "{{count}} favori",
"{{displayName}}, your call has ended.": "{{displayName}}, votre appel est terminé.", "{{displayName}}, your call has ended.": "{{displayName}}, votre appel est terminé.",
"<0>Thanks for your feedback!</0>": "<0>Merci pour votre commentaire !</0>", "<0>Thanks for your feedback!</0>": "<0>Merci pour votre commentaire !</0>",
"How did it go?": "Comment cela sest-il passé ?" "How did it go?": "Comment cela sest-il passé ?",
"{{displayName}} is presenting": "{{displayName}} est à lécran",
"Show connection stats": "Afficher les statistiques de la connexion"
} }

View file

@ -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>": "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}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "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}</1>",
"Password": "Kata sandi", "Password": "Kata sandi",
"Passwords must match": "Kata sandi harus cocok", "Passwords must match": "Kata sandi harus cocok",
"Press and hold to talk over {{name}}": "Tekan dan tahan untuk berbicara pada {{name}}",
"Profile": "Profil", "Profile": "Profil",
"Recaptcha dismissed": "Recaptcha ditutup", "Recaptcha dismissed": "Recaptcha ditutup",
"Recaptcha not loaded": "Recaptcha tidak dimuat", "Recaptcha not loaded": "Recaptcha tidak dimuat",
@ -113,5 +112,8 @@
"<0>Thanks for your feedback!</0>": "<0>Terima kasih atas masukan Anda!</0>", "<0>Thanks for your feedback!</0>": "<0>Terima kasih atas masukan Anda!</0>",
"How did it go?": "Bagaimana rasanya?", "How did it go?": "Bagaimana rasanya?",
"{{count}} stars|one": "{{count}} bintang", "{{count}} stars|one": "{{count}} bintang",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Kami ingin mendengar masukan Anda supaya kami bisa meningkatkan pengalaman Anda.</0>" "<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Kami ingin mendengar masukan Anda supaya kami bisa meningkatkan pengalaman Anda.</0>",
"Show connection stats": "Tampilkan statistik koneksi",
"{{displayName}} is presenting": "{{displayName}} sedang menampilkan",
"{{count}} stars|other": "{{count}} bintang"
} }

View file

@ -1,5 +1,4 @@
{ {
"{{name}} (Waiting for video...)": "{{name}}(ビデオを待機しています…)",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>既にアカウントをお持ちですか?</0><1><0>ログイン</0>または<2>ゲストとしてアクセス</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>既にアカウントをお持ちですか?</0><1><0>ログイン</0>または<2>ゲストとしてアクセス</2></1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>アカウントを作成</0>または<2>ゲストとしてアクセス</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>アカウントを作成</0>または<2>ゲストとしてアクセス</2>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>今すぐ通話に参加</0><1>または</1><2>通話リンクをコピーし、後で参加</2>", "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>今すぐ通話に参加</0><1>または</1><2>通話リンクをコピーし、後で参加</2>",

View file

@ -40,7 +40,6 @@
"Recaptcha not loaded": "Recaptcha nie została załadowana", "Recaptcha not loaded": "Recaptcha nie została załadowana",
"Recaptcha dismissed": "Recaptcha odrzucona", "Recaptcha dismissed": "Recaptcha odrzucona",
"Profile": "Profil", "Profile": "Profil",
"Press and hold spacebar to talk": "Przytrzymaj spację, aby mówić",
"Passwords must match": "Hasła muszą pasować", "Passwords must match": "Hasła muszą pasować",
"Password": "Hasło", "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}</1>": "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}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "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}</1>",
@ -93,7 +92,6 @@
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Utwórz konto</0> lub <2>Dołącz jako gość</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Utwórz konto</0> lub <2>Dołącz jako gość</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Masz już konto?</0><1><0>Zaloguj się</0> lub <2>Dołącz jako gość</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Masz już konto?</0><1><0>Zaloguj się</0> lub <2>Dołącz jako gość</2></1>",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",
"This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.",
"Copy": "Kopiuj", "Copy": "Kopiuj",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Wysłanie dzienników debuggowania pomoże nam ustalić przyczynę problemu.</0>", "<0>Submitting debug logs will help us track down the problem.</0>": "<0>Wysłanie dzienników debuggowania pomoże nam ustalić przyczynę problemu.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Ojej, coś poszło nie tak.</0>", "<0>Oops, something's gone wrong.</0>": "<0>Ojej, coś poszło nie tak.</0>",

View file

@ -10,7 +10,6 @@
"Submit feedback": "Отправить отзыв", "Submit feedback": "Отправить отзыв",
"Sending debug logs…": "Отправка журнала отладки…", "Sending debug logs…": "Отправка журнала отладки…",
"Select an option": "Выберите вариант", "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>": "Другие пользователи пытаются присоединиться с неподдерживаемых версий программы. Этим участникам надо перезагрузить браузер: <1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Другие пользователи пытаются присоединиться с неподдерживаемых версий программы. Этим участникам надо перезагрузить браузер: <1>{userLis}</1>",
"Grid layout menu": "Меню \"Расположение сеткой\"", "Grid layout menu": "Меню \"Расположение сеткой\"",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Нажимая \"Присоединиться сейчас\", вы соглашаетесь с нашими <2>положениями и условиями</2>", "By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Нажимая \"Присоединиться сейчас\", вы соглашаетесь с нашими <2>положениями и условиями</2>",
@ -94,7 +93,6 @@
"Call link copied": "Ссылка на звонок скопирована", "Call link copied": "Ссылка на звонок скопирована",
"Avatar": "Аватар", "Avatar": "Аватар",
"Audio": "Аудио", "Audio": "Аудио",
"{{name}} is presenting": "{{name}} показывает",
"Element Call Home": "Главная Element Call", "Element Call Home": "Главная Element Call",
"Copy": "Копировать", "Copy": "Копировать",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Присоединиться сейчас к звонку</0><1>или<1><2>Скопировать ссылку на звонок и присоединиться позже</2>", "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Присоединиться сейчас к звонку</0><1>или<1><2>Скопировать ссылку на звонок и присоединиться позже</2>",

View file

@ -22,7 +22,6 @@
"Recaptcha not loaded": "Recaptcha sa nenačítala", "Recaptcha not loaded": "Recaptcha sa nenačítala",
"Recaptcha dismissed": "Recaptcha zamietnutá", "Recaptcha dismissed": "Recaptcha zamietnutá",
"Profile": "Profil", "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ť", "Passwords must match": "Heslá sa musia zhodovať",
"Password": "Heslo", "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}</1>": "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}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "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}</1>",
@ -97,7 +96,6 @@
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Vytvoriť konto</0> Alebo <2>Prihlásiť sa ako hosť</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Vytvoriť konto</0> Alebo <2>Prihlásiť sa ako hosť</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Už máte konto?</0><1><0>Prihláste sa</0> Alebo <2>Prihlásiť sa ako hosť</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Už máte konto?</0><1><0>Prihláste sa</0> Alebo <2>Prihlásiť sa ako hosť</2></1>",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} (Connecting...)": "{{name}} (Pripájanie...)",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Odoslanie záznamov ladenia nám pomôže nájsť problém.</0>", "<0>Submitting debug logs will help us track down the problem.</0>": "<0>Odoslanie záznamov ladenia nám pomôže nájsť problém.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Hups, niečo sa pokazilo.</0>", "<0>Oops, something's gone wrong.</0>": "<0>Hups, niečo sa pokazilo.</0>",
"Expose developer settings in the settings window.": "Zobraziť nastavenia pre vývojárov v okne nastavení.", "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", "{{count}} stars|other": "{{count}} hviezdičiek",
"{{displayName}}, your call has ended.": "{{displayName}}, váš hovor skončil.", "{{displayName}}, your call has ended.": "{{displayName}}, váš hovor skončil.",
"<0>Thanks for your feedback!</0>": "<0> Ďakujeme za vašu spätnú väzbu!</0>", "<0>Thanks for your feedback!</0>": "<0> Ďakujeme za vašu spätnú väzbu!</0>",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0> Radi si vypočujeme vašu spätnú väzbu, aby sme mohli zlepšiť vaše skúsenosti.</0>" "<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0> Radi si vypočujeme vašu spätnú väzbu, aby sme mohli zlepšiť vaše skúsenosti.</0>",
"{{displayName}} is presenting": "{{displayName}} prezentuje",
"Show connection stats": "Zobraziť štatistiky pripojenia"
} }

View file

@ -39,7 +39,6 @@
"Recaptcha not loaded": "Recaptcha не завантажено", "Recaptcha not loaded": "Recaptcha не завантажено",
"Recaptcha dismissed": "Recaptcha не пройдено", "Recaptcha dismissed": "Recaptcha не пройдено",
"Profile": "Профіль", "Profile": "Профіль",
"Press and hold spacebar to talk": "Затисніть пробіл, щоб говорити",
"Passwords must match": "Паролі відрізняються", "Passwords must match": "Паролі відрізняються",
"Password": "Пароль", "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>": "Інші користувачі намагаються приєднатися до цього виклику з несумісних версій. Ці користувачі повинні переконатися, що вони оновили сторінки своїх браузерів:<1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Інші користувачі намагаються приєднатися до цього виклику з несумісних версій. Ці користувачі повинні переконатися, що вони оновили сторінки своїх браузерів:<1>{userLis}</1>",
@ -94,7 +93,6 @@
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Створити обліковий запис</0> або <2>Отримати доступ як гість</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Створити обліковий запис</0> або <2>Отримати доступ як гість</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Уже маєте обліковий запис?</0><1><0>Увійти</0> Or <2>Отримати доступ як гість</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Уже маєте обліковий запис?</0><1><0>Увійти</0> Or <2>Отримати доступ як гість</2></1>",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",
"{{count}} people connected|one": "{{count}} під'єднується",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Приєднатися до виклику зараз</0><1>Or</1><2>Скопіювати посилання на виклик і приєднатися пізніше</2>", "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Приєднатися до виклику зараз</0><1>Or</1><2>Скопіювати посилання на виклик і приєднатися пізніше</2>",
"Element Call Home": "Домівка Element Call", "Element Call Home": "Домівка Element Call",
"Copy": "Копіювати", "Copy": "Копіювати",
@ -115,5 +113,7 @@
"{{count}} stars|other": "{{count}} зірок", "{{count}} stars|other": "{{count}} зірок",
"{{displayName}}, your call has ended.": "{{displayName}}, ваш виклик завершено.", "{{displayName}}, your call has ended.": "{{displayName}}, ваш виклик завершено.",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Ми будемо раді почути ваші відгуки, щоб поліпшити роботу застосунку.</0>", "<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Ми будемо раді почути ваші відгуки, щоб поліпшити роботу застосунку.</0>",
"How did it go?": "Вам усе сподобалось?" "How did it go?": "Вам усе сподобалось?",
"{{displayName}} is presenting": "{{displayName}} представляє",
"Show connection stats": "Показати стан з'єднання"
} }

View file

@ -31,7 +31,6 @@
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>创建账户</0> Or <2>以访客身份继续</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>创建账户</0> Or <2>以访客身份继续</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>已有账户?</0><1><0>登录</0> Or <2>以访客身份继续</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>已有账户?</0><1><0>登录</0> Or <2>以访客身份继续</2></1>",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} (Connecting...)": "{{name}} (正在连接……)",
"Inspector": "检查器", "Inspector": "检查器",
"Show call inspector": "显示通话检查器", "Show call inspector": "显示通话检查器",
"Share screen": "屏幕共享", "Share screen": "屏幕共享",
@ -47,7 +46,6 @@
"Recaptcha not loaded": "reCaptcha未加载", "Recaptcha not loaded": "reCaptcha未加载",
"Recaptcha dismissed": "reCaptcha验证失败", "Recaptcha dismissed": "reCaptcha验证失败",
"Profile": "个人信息", "Profile": "个人信息",
"Press and hold spacebar to talk": "按住空格键发言",
"Passwords must match": "密码必须匹配", "Passwords must match": "密码必须匹配",
"Password": "密码", "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>": "其他用户正试图从不兼容的版本加入这一呼叫。这些用户应该确保已经刷新了浏览器:<1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "其他用户正试图从不兼容的版本加入这一呼叫。这些用户应该确保已经刷新了浏览器:<1>{userLis}</1>",

View file

@ -3,7 +3,6 @@
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>建立帳號</0> 或<2>以訪客身份登入</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>建立帳號</0> 或<2>以訪客身份登入</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>已經有帳號?</0><1><0>登入</0> 或<2>以訪客身份登入</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>已經有帳號?</0><1><0>登入</0> 或<2>以訪客身份登入</2></1>",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} (Connecting...)": "{{name}} (連結中...)",
"Expose developer settings in the settings window.": "在設定視窗中顯示開發者設定。", "Expose developer settings in the settings window.": "在設定視窗中顯示開發者設定。",
"Developer Settings": "開發者設定", "Developer Settings": "開發者設定",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>送出除錯紀錄,可幫助我們修正問題。</0>", "<0>Submitting debug logs will help us track down the problem.</0>": "<0>送出除錯紀錄,可幫助我們修正問題。</0>",
@ -47,7 +46,6 @@
"Recaptcha not loaded": "驗證碼未載入", "Recaptcha not loaded": "驗證碼未載入",
"Recaptcha dismissed": "略過驗證碼", "Recaptcha dismissed": "略過驗證碼",
"Profile": "個人檔案", "Profile": "個人檔案",
"Press and hold spacebar to talk": "說話時請按住空白鍵",
"Passwords must match": "密碼必須相符", "Passwords must match": "密碼必須相符",
"Password": "密碼", "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>": "有使用者試著加入通話,但他們的軟體版本不相容。這些使用者需要確認已將瀏覽器更新到最新版本:<1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "有使用者試著加入通話,但他們的軟體版本不相容。這些使用者需要確認已將瀏覽器更新到最新版本:<1>{userLis}</1>",
@ -115,5 +113,7 @@
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>我們想要聽到您的回饋,如此我們才能改善您的體驗。</0>", "<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>我們想要聽到您的回饋,如此我們才能改善您的體驗。</0>",
"{{count}} stars|one": "{{count}} 個星星", "{{count}} stars|one": "{{count}} 個星星",
"{{displayName}}, your call has ended.": "{{displayName}},您的通話已結束。", "{{displayName}}, your call has ended.": "{{displayName}},您的通話已結束。",
"How did it go?": "進展如何?" "How did it go?": "進展如何?",
"{{displayName}} is presenting": "{{displayName}} 正在展示",
"Show connection stats": "顯示連線統計資料"
} }

View file

@ -16,6 +16,8 @@ limitations under the License.
import { ResizeObserver } from "@juggle/resize-observer"; import { ResizeObserver } from "@juggle/resize-observer";
import { import {
RoomAudioRenderer,
RoomContext,
useLocalParticipant, useLocalParticipant,
useParticipants, useParticipants,
useTracks, useTracks,
@ -80,6 +82,8 @@ import { VideoTile } from "../video-grid/VideoTile";
import { UserChoices, useLiveKit } from "../livekit/useLiveKit"; import { UserChoices, useLiveKit } from "../livekit/useLiveKit";
import { useMediaDevices } from "../livekit/useMediaDevices"; import { useMediaDevices } from "../livekit/useMediaDevices";
import { SFUConfig } from "../livekit/openIDSFU"; import { SFUConfig } from "../livekit/openIDSFU";
import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams // There is currently a bug in Safari our our code with cloning and sending MediaStreams
@ -95,7 +99,13 @@ export interface ActiveCallProps extends Omit<InCallViewProps, "livekitRoom"> {
export function ActiveCall(props: ActiveCallProps) { export function ActiveCall(props: ActiveCallProps) {
const livekitRoom = useLiveKit(props.userChoices, props.sfuConfig); const livekitRoom = useLiveKit(props.userChoices, props.sfuConfig);
return livekitRoom && <InCallView {...props} livekitRoom={livekitRoom} />; return (
livekitRoom && (
<RoomContext.Provider value={livekitRoom}>
<InCallView {...props} livekitRoom={livekitRoom} />
</RoomContext.Provider>
)
);
} }
export interface InCallViewProps { export interface InCallViewProps {
@ -167,9 +177,6 @@ export function InCallView({
const toggleCamera = useCallback(async () => { const toggleCamera = useCallback(async () => {
await localParticipant.setCameraEnabled(!isCameraEnabled); await localParticipant.setCameraEnabled(!isCameraEnabled);
}, [localParticipant, isCameraEnabled]); }, [localParticipant, isCameraEnabled]);
const toggleScreensharing = useCallback(async () => {
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled);
}, [localParticipant, isScreenShareEnabled]);
const joinRule = useJoinRule(groupCall.room); const joinRule = useJoinRule(groupCall.room);
@ -222,15 +229,19 @@ export function InCallView({
const noControls = reducedControls && bounds.height <= 400; const noControls = reducedControls && bounds.height <= 400;
const items = useParticipantTiles(livekitRoom, participants); 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 // window is too small to show everyone
const maximisedParticipant = useMemo( const maximisedParticipant = useMemo(
() => () =>
noControls fullscreenItem ??
? items.find((item) => item.focused) ?? items.at(0) ?? null (noControls
: null, ? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
[noControls, items] : null),
[fullscreenItem, noControls, items]
); );
const Grid = const Grid =
@ -238,6 +249,10 @@ export function InCallView({
const prefersReducedMotion = usePrefersReducedMotion(); 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 => { const renderContent = (): JSX.Element => {
if (items.length === 0) { if (items.length === 0) {
return ( return (
@ -249,6 +264,9 @@ export function InCallView({
if (maximisedParticipant) { if (maximisedParticipant) {
return ( return (
<VideoTile <VideoTile
maximised={true}
fullscreen={maximisedParticipant === fullscreenItem}
onToggleFullscreen={toggleFullscreen}
targetHeight={bounds.height} targetHeight={bounds.height}
targetWidth={bounds.width} targetWidth={bounds.width}
key={maximisedParticipant.id} key={maximisedParticipant.id}
@ -264,9 +282,13 @@ export function InCallView({
items={items} items={items}
layout={layout} layout={layout}
disableAnimations={prefersReducedMotion || isSafari} disableAnimations={prefersReducedMotion || isSafari}
layoutStates={layoutStates}
> >
{(props) => ( {(props) => (
<VideoTile <VideoTile
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
showSpeakingIndicator={items.length > 2} showSpeakingIndicator={items.length > 2}
showConnectionStats={showConnectionStats} showConnectionStats={showConnectionStats}
{...props} {...props}
@ -316,6 +338,11 @@ export function InCallView({
[styles.maximised]: undefined, [styles.maximised]: undefined,
}); });
const toggleScreensharing = useCallback(async () => {
exitFullscreen();
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled);
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
let footer: JSX.Element | null; let footer: JSX.Element | null;
if (noControls) { if (noControls) {
@ -349,9 +376,7 @@ export function InCallView({
/> />
); );
} }
if (!maximisedParticipant) { buttons.push(<SettingsButton key="4" onPress={openSettings} />);
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
}
} }
buttons.push( buttons.push(
@ -362,7 +387,7 @@ export function InCallView({
return ( return (
<div className={containerClasses} ref={containerRef}> <div className={containerClasses} ref={containerRef}>
{!hideHeader && ( {!hideHeader && maximisedParticipant === null && (
<Header> <Header>
<LeftNav> <LeftNav>
<RoomHeaderInfo <RoomHeaderInfo
@ -383,6 +408,7 @@ export function InCallView({
</Header> </Header>
)} )}
<div className={styles.controlsOverlay}> <div className={styles.controlsOverlay}>
<RoomAudioRenderer />
{renderContent()} {renderContent()}
{footer} {footer}
</div> </div>
@ -464,6 +490,7 @@ function useParticipantTiles(
local: sfuParticipant.isLocal, local: sfuParticipant.isLocal,
largeBaseSize: false, largeBaseSize: false,
data: { data: {
id,
member, member,
sfuParticipant, sfuParticipant,
content: TileContent.UserMedia, 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. // If there is a screen sharing enabled for this participant, create a tile for it as well.
let screenShareTile: TileDescriptor<ItemData> | undefined; let screenShareTile: TileDescriptor<ItemData> | undefined;
if (sfuParticipant.isScreenShareEnabled) { if (sfuParticipant.isScreenShareEnabled) {
const screenShareId = `${id}:screen-share`;
screenShareTile = { screenShareTile = {
...userMediaTile, ...userMediaTile,
id: `${id}:screen-share`, id: screenShareId,
focused: true, focused: true,
largeBaseSize: true, largeBaseSize: true,
placeNear: id, placeNear: id,
data: { data: {
...userMediaTile.data, ...userMediaTile.data,
id: screenShareId,
content: TileContent.ScreenShare, content: TileContent.ScreenShare,
}, },
}; };

114
src/room/useFullscreen.ts Normal file
View file

@ -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<T>(items: TileDescriptor<T>[]): {
fullscreenItem: TileDescriptor<T> | null;
toggleFullscreen: (itemId: string) => void;
exitFullscreen: () => void;
} {
const [fullscreenItem, setFullscreenItem] =
useReactiveState<TileDescriptor<T> | null>(
(prevItem) =>
prevItem == null
? null
: items.find((i) => i.id === prevItem.id) ?? null,
[items]
);
const latestItems = useRef<TileDescriptor<T>[]>(items);
latestItems.current = items;
const latestFullscreenItem = useRef<TileDescriptor<T> | 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,
};
}

View file

@ -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;
}
}

View file

@ -15,33 +15,48 @@ limitations under the License.
*/ */
import TinyQueue from "tinyqueue"; 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 { TileDescriptor } from "./VideoGrid";
import { Slot } from "./NewVideoGrid";
import { Layout } from "./Layout";
import { count, findLastIndex } from "../array-utils"; import { count, findLastIndex } from "../array-utils";
import styles from "./BigGrid.module.css";
/** /**
* A 1×1 cell in a grid which belongs to a tile. * A 1×1 cell in a grid which belongs to a tile.
*/ */
export interface Cell { interface Cell {
/** /**
* The item displayed on the tile. * The item displayed on the tile.
*/ */
item: TileDescriptor<unknown>; readonly item: TileDescriptor<unknown>;
/** /**
* Whether this cell is the origin (top left corner) of the tile. * Whether this cell is the origin (top left corner) of the tile.
*/ */
origin: boolean; readonly origin: boolean;
/** /**
* The width, in columns, of the tile. * The width, in columns, of the tile.
*/ */
columns: number; readonly columns: number;
/** /**
* The height, in rows, of the tile. * 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; columns: number;
/** /**
* The cells of the grid, in left-to-right top-to-bottom order. * 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 * @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. * 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 destRow = row(dest, g);
const destColumn = column(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)[]; 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); 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); 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; 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 indexColumn = column(index, g);
const indexRow = row(index, g); const indexRow = row(index, g);
return ( return (
@ -131,7 +151,7 @@ function inArea(index: number, start: number, end: number, g: Grid): boolean {
function* cellsInArea( function* cellsInArea(
start: number, start: number,
end: number, end: number,
g: Grid g: BigGridState
): Generator<number, void, unknown> { ): Generator<number, void, unknown> {
const startColumn = column(start, g); const startColumn = column(start, g);
const endColumn = column(end, g); const endColumn = column(end, g);
@ -149,7 +169,7 @@ function* cellsInArea(
export function forEachCellInArea( export function forEachCellInArea(
start: number, start: number,
end: number, end: number,
g: Grid, g: BigGridState,
fn: (c: Cell | undefined, i: number) => void fn: (c: Cell | undefined, i: number) => void
): void { ): void {
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i); for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
@ -158,7 +178,7 @@ export function forEachCellInArea(
function allCellsInArea( function allCellsInArea(
start: number, start: number,
end: number, end: number,
g: Grid, g: BigGridState,
fn: (c: Cell | undefined, i: number) => boolean fn: (c: Cell | undefined, i: number) => boolean
): boolean { ): boolean {
for (const i of cellsInArea(start, end, g)) { for (const i of cellsInArea(start, end, g)) {
@ -172,16 +192,19 @@ const areaEnd = (
start: number, start: number,
columns: number, columns: number,
rows: number, rows: number,
g: Grid g: BigGridState
): number => start + columns - 1 + g.columns * (rows - 1); ): 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 * Gets the index of the next gap in the grid that should be backfilled by 1×1
* tiles. * tiles.
*/ */
function getNextGap(g: Grid): number | null { function getNextGap(g: BigGridState): number | null {
const last1By1Index = findLast1By1Index(g); const last1By1Index = findLast1By1Index(g);
if (last1By1Index === null) return null; 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. * 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); const initialColumn = column(index, g);
for ( for (
@ -229,7 +252,7 @@ function getOrigin(g: Grid, index: number): number {
* along the way. * along the way.
* Precondition: the destination area must consist of only 1×1 tiles. * 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 tile = g.cells[from]!;
const fromEnd = areaEnd(from, tile.columns, tile.rows, g); const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
const toEnd = areaEnd(to, 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. * 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]!; const tile = g.cells[from]!;
if ( if (
to !== from && // Skip the operation if nothing would move
to >= 0 && to >= 0 &&
to < g.cells.length && to < g.cells.length &&
column(to, g) <= g.columns - tile.columns 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)) { if (allCellsInArea(to, toEnd, g, displaceable)) {
// The target space is free; move // The target space is free; move
const gClone = cloneGrid(g); const gClone = cloneGrid(g);
moveTile(gClone, from, to); moveTileUnchecked(gClone, from, to);
return gClone; return gClone;
} }
} }
@ -297,7 +325,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
* enlarged tiles around when necessary. * enlarged tiles around when necessary.
* @returns Whether the tile was actually pushed * @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]!; const tile = g.cells[from]!;
// TODO: pushing large tiles sideways might be more successful in some // TODO: pushing large tiles sideways might be more successful in some
@ -315,7 +343,7 @@ function pushTileUp(g: Grid, from: number): boolean {
); );
if (cellsAboveAreDisplacable) { if (cellsAboveAreDisplacable) {
moveTile(g, from, from - g.columns); moveTileUnchecked(g, from, from - g.columns);
return true; return true;
} else { } else {
return false; return false;
@ -325,8 +353,8 @@ function pushTileUp(g: Grid, from: number): boolean {
/** /**
* Backfill any gaps in the grid. * Backfill any gaps in the grid.
*/ */
export function fillGaps(g: Grid): Grid { export function fillGaps(g: BigGridState): BigGridState {
const result = cloneGrid(g); const result = cloneGrid(g) as MutableBigGridState;
// This will hopefully be the size of the grid after we're done here, assuming // This will hopefully be the size of the grid after we're done here, assuming
// that we can pack the large tiles tightly enough // that we can pack the large tiles tightly enough
@ -403,7 +431,11 @@ export function fillGaps(g: Grid): Grid {
return result; return result;
} }
function createRows(g: Grid, count: number, atRow: number): Grid { function createRows(
g: BigGridState,
count: number,
atRow: number
): BigGridState {
const result = { const result = {
columns: g.columns, columns: g.columns,
cells: new Array(g.cells.length + g.columns * count), 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<unknown>[], g: Grid): Grid { export function addItems(
items: TileDescriptor<unknown>[],
g: BigGridState
): BigGridState {
let result = cloneGrid(g); let result = cloneGrid(g);
for (const item of items) { for (const item of items) {
@ -444,13 +479,11 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
}; };
let placeAt: number; let placeAt: number;
let hasGaps: boolean;
if (item.placeNear === undefined) { if (item.placeNear === undefined) {
// This item has no special placement requests, so let's put it // This item has no special placement requests, so let's put it
// uneventfully at the end of the grid // uneventfully at the end of the grid
placeAt = result.cells.length; placeAt = result.cells.length;
hasGaps = false;
} else { } else {
// This item wants to be placed near another; let's put it on a row // This item wants to be placed near another; let's put it on a row
// directly below the related tile // directly below the related tile
@ -460,7 +493,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
if (placeNear === -1) { if (placeNear === -1) {
// Can't find the related tile, so let's give up and place it at the end // Can't find the related tile, so let's give up and place it at the end
placeAt = result.cells.length; placeAt = result.cells.length;
hasGaps = false;
} else { } else {
const placeNearCell = result.cells[placeNear]!; const placeNearCell = result.cells[placeNear]!;
const placeNearEnd = areaEnd( const placeNearEnd = areaEnd(
@ -475,7 +507,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
placeNear + placeNear +
Math.floor(placeNearCell.columns / 2) + Math.floor(placeNearCell.columns / 2) +
result.columns * placeNearCell.rows; result.columns * placeNearCell.rows;
hasGaps = true;
} }
} }
@ -484,21 +515,19 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
if (item.largeBaseSize) { if (item.largeBaseSize) {
// Cycle the tile size once to set up the tile with its larger base size // 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 // This also fills any gaps in the grid, hence no extra call to fillGaps
result = cycleTileSize(item.id, result); result = cycleTileSize(result, item);
} else if (hasGaps) {
result = fillGaps(result);
} }
} }
return result; return result;
} }
const largeTileDimensions = (g: Grid): [number, number] => [ const largeTileDimensions = (g: BigGridState): [number, number] => [
Math.min(3, Math.max(2, g.columns - 1)), Math.min(3, Math.max(2, g.columns - 1)),
2, 2,
]; ];
const extraLargeTileDimensions = (g: Grid): [number, number] => const extraLargeTileDimensions = (g: BigGridState): [number, number] =>
g.columns > 3 ? [4, 3] : [g.columns, 2]; g.columns > 3 ? [4, 3] : [g.columns, 2];
/** /**
@ -507,8 +536,11 @@ const extraLargeTileDimensions = (g: Grid): [number, number] =>
* @param g The grid. * @param g The grid.
* @returns The updated grid. * @returns The updated grid.
*/ */
export function cycleTileSize(tileId: string, g: Grid): Grid { export function cycleTileSize(
const from = g.cells.findIndex((c) => c?.item.id === tileId); g: BigGridState,
tile: TileDescriptor<unknown>
): BigGridState {
const from = g.cells.findIndex((c) => c?.item === tile);
if (from === -1) return g; // Tile removed, no change if (from === -1) return g; // Tile removed, no change
const fromCell = g.cells[from]!; const fromCell = g.cells[from]!;
const fromWidth = fromCell.columns; const fromWidth = fromCell.columns;
@ -629,8 +661,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
/** /**
* Resizes the grid to a new column width. * Resizes the grid to a new column width.
*/ */
export function resize(g: Grid, columns: number): Grid { export function resize(g: BigGridState, columns: number): BigGridState {
const result: Grid = { columns, cells: [] }; const result: BigGridState = { columns, cells: [] };
const [largeColumns, largeRows] = largeTileDimensions(result); const [largeColumns, largeRows] = largeTileDimensions(result);
// Copy each tile from the old grid to the resized one in the same order // 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) { for (const cell of g.cells) {
if (cell?.origin) { if (cell?.origin) {
// TODO make aware of extra large tiles
const [nextColumns, nextRows] = const [nextColumns, nextRows] =
cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1]; 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. * 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 // 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 // will stick with this approach in the long run
// We assume that 4 rows are probably about 1 page // We assume that 4 rows are probably about 1 page
@ -694,10 +727,149 @@ export function promoteSpeakers(g: Grid) {
toCell === undefined || toCell === undefined ||
(toCell.columns === 1 && toCell.rows === 1) (toCell.columns === 1 && toCell.rows === 1)
) { ) {
moveTile(g, from, to); moveTileUnchecked(g, from, to);
break; break;
} }
} }
} }
} }
} }
/**
* The algorithm for updating a grid with a new set of tiles.
*/
function updateTiles(
g: BigGridState,
tiles: TileDescriptor<unknown>[]
): 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<number | null>(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<ReactNode>(slotCount);
for (let i = 0; i < slotCount; i++)
slots[i] = <Slot key={i} style={{ gridArea: `s${i}` }} />;
return (
<div className={styles.bigGrid} style={style}>
{slots}
</div>
);
});
/**
* 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<unknown>,
to: TileDescriptor<unknown>,
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<BigGridState> = {
emptyState: { columns: 4, cells: [] },
updateTiles,
updateBounds,
getTiles: <T,>(g) =>
g.cells.filter((c) => c?.origin).map((c) => c!.item as T),
canDragTile: () => true,
dragTile,
toggleFocus: cycleTileSize,
Slots,
rememberState: false,
};

178
src/video-grid/Layout.tsx Normal file
View file

@ -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<State> {
/**
* 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: <T>(s: State, tiles: TileDescriptor<T>[]) => 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: <T>(s: State) => TileDescriptor<T>[];
/**
* Determines whether a tile is draggable.
*/
readonly canDragTile: <T>(s: State, tile: TileDescriptor<T>) => 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: <T>(
s: State,
from: TileDescriptor<T>,
to: TileDescriptor<T>,
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?: <T>(s: State, tile: TileDescriptor<T>) => 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<State>(layout: Layout<State>): State | undefined;
set<State>(layout: Layout<State>, state: State): LayoutStatesMap;
delete<State>(layout: Layout<State>): boolean;
}
/**
* Hook creating a Map to store layout states in.
*/
export const useLayoutStates = (): LayoutStatesMap => {
const layoutStates = useRef<Map<unknown, unknown>>();
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 = <State, T>(
layout: Layout<State>,
items: TileDescriptor<T>[],
bounds: RectReadOnly,
layoutStates: LayoutStatesMap
) => {
const prevLayout = useRef<Layout<unknown>>();
const prevState = layoutStates.get(layout);
const [state, setState] = useReactiveState<State>(() => {
// 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<number>(0);
if (state !== prevState) generation.current++;
prevLayout.current = layout as Layout<unknown>;
// 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<T>(state), [layout, state]),
generation: generation.current,
canDragTile: useCallback(
(tile: TileDescriptor<T>) => layout.canDragTile(state, tile),
[layout, state]
),
dragTile: useCallback(
(
from: TileDescriptor<T>,
to: TileDescriptor<T>,
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<T>) =>
setState((s) => layout.toggleFocus!(s, tile))),
[layout, setState]
),
slots: <layout.Slots s={state} />,
};
};

View file

@ -23,11 +23,8 @@ limitations under the License.
overflow-x: hidden; overflow-x: hidden;
} }
.slotGrid { .slots {
position: relative; position: relative;
display: grid;
grid-auto-rows: 163px;
gap: 8px;
} }
.slot { .slot {
@ -38,10 +35,4 @@ limitations under the License.
.grid { .grid {
padding: 0 22px var(--footerHeight); padding: 0 22px var(--footerHeight);
} }
.slotGrid {
grid-auto-rows: 183px;
column-gap: 18px;
row-gap: 21px;
}
} }

View file

@ -17,17 +17,16 @@ limitations under the License.
import { SpringRef, TransitionFn, useTransition } from "@react-spring/web"; import { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
import { EventTypes, Handler, useScroll } from "@use-gesture/react"; import { EventTypes, Handler, useScroll } from "@use-gesture/react";
import React, { import React, {
Dispatch, CSSProperties,
FC,
ReactNode, ReactNode,
SetStateAction,
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { zipWith } from "lodash"; import { zip } from "lodash";
import styles from "./NewVideoGrid.module.css"; import styles from "./NewVideoGrid.module.css";
import { import {
@ -38,99 +37,9 @@ import {
} from "./VideoGrid"; } from "./VideoGrid";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import {
Grid,
Cell,
row,
column,
fillGaps,
forEachCellInArea,
cycleTileSize,
addItems,
tryMoveTile,
resize,
promoteSpeakers,
} from "./model";
import { TileWrapper } from "./TileWrapper"; import { TileWrapper } from "./TileWrapper";
import { BigGrid } from "./BigGrid";
interface GridState extends Grid { import { useLayout } from "./Layout";
/**
* The ID of the current state of the grid.
*/
generation: number;
}
const useGridState = (
columns: number | null,
items: TileDescriptor<unknown>[]
): [GridState | null, Dispatch<SetStateAction<Grid>>] => {
const [grid, setGrid_] = useReactiveState<GridState | null>(
(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<SetStateAction<Grid>> = 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];
};
interface Rect { interface Rect {
x: number; x: number;
@ -139,8 +48,8 @@ interface Rect {
height: number; height: number;
} }
interface Tile extends Rect { interface Tile<T> extends Rect {
item: TileDescriptor<unknown>; item: TileDescriptor<T>;
} }
interface DragState { interface DragState {
@ -151,12 +60,21 @@ interface DragState {
cursorY: number; cursorY: number;
} }
interface SlotProps {
style?: CSSProperties;
}
export const Slot: FC<SlotProps> = ({ style }) => (
<div className={styles.slot} style={style} />
);
/** /**
* An interactive, animated grid of video tiles. * An interactive, animated grid of video tiles.
*/ */
export function NewVideoGrid<T>({ export function NewVideoGrid<T>({
items, items,
disableAnimations, disableAnimations,
layoutStates,
children, children,
}: Props<T>) { }: Props<T>) {
// Overview: This component lays out tiles by rendering an invisible template // Overview: This component lays out tiles by rendering an invisible template
@ -169,36 +87,36 @@ export function NewVideoGrid<T>({
// most recently rendered generation of the grid, and watch it with a // most recently rendered generation of the grid, and watch it with a
// MutationObserver. // MutationObserver.
const [slotGrid, setSlotGrid] = useState<HTMLDivElement | null>(null); const [slotsRoot, setSlotsRoot] = useState<HTMLDivElement | null>(null);
const [slotGridGeneration, setSlotGridGeneration] = useState(0); const [renderedGeneration, setRenderedGeneration] = useState(0);
useEffect(() => { useEffect(() => {
if (slotGrid !== null) { if (slotsRoot !== null) {
setSlotGridGeneration( setRenderedGeneration(
parseInt(slotGrid.getAttribute("data-generation")!) parseInt(slotsRoot.getAttribute("data-generation")!)
); );
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
if (mutations.some((m) => m.type === "attributes")) { if (mutations.some((m) => m.type === "attributes")) {
setSlotGridGeneration( setRenderedGeneration(
parseInt(slotGrid.getAttribute("data-generation")!) parseInt(slotsRoot.getAttribute("data-generation")!)
); );
} }
}); });
observer.observe(slotGrid, { attributes: true }); observer.observe(slotsRoot, { attributes: true });
return () => observer.disconnect(); return () => observer.disconnect();
} }
}, [slotGrid, setSlotGridGeneration]); }, [slotsRoot, setRenderedGeneration]);
const [gridRef1, gridBounds] = useMeasure(); const [gridRef1, gridBounds] = useMeasure();
const gridRef2 = useRef<HTMLDivElement | null>(null); const gridRef2 = useRef<HTMLDivElement | null>(null);
const gridRef = useMergedRefs(gridRef1, gridRef2); const gridRef = useMergedRefs(gridRef1, gridRef2);
const slotRects = useMemo(() => { 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<Rect>(slots.length); const rects = new Array<Rect>(slots.length);
for (let i = 0; i < slots.length; i++) { for (let i = 0; i < slots.length; i++) {
const slot = slots[i] as HTMLElement; const slot = slots[i] as HTMLElement;
@ -214,32 +132,34 @@ export function NewVideoGrid<T>({
// The rects may change due to the grid being resized or rerendered, but // The rects may change due to the grid being resized or rerendered, but
// eslint can't statically verify this // eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [slotGrid, slotGridGeneration, gridBounds]); }, [slotsRoot, renderedGeneration, gridBounds]);
const columns = useMemo( // TODO: Implement more layouts and select the right one here
() => const layout = BigGrid;
// The grid bounds might not be known yet const {
gridBounds.width === 0 state: grid,
? null orderedItems,
: Math.max(2, Math.floor(gridBounds.width * 0.0045)), generation,
[gridBounds] canDragTile,
); dragTile,
toggleFocus,
slots,
} = useLayout(layout, items, gridBounds, layoutStates);
const [grid, setGrid] = useGridState(columns, items); const [tiles] = useReactiveState<Tile<T>[]>(
const [tiles] = useReactiveState<Tile[]>(
(prevTiles) => { (prevTiles) => {
// If React hasn't yet rendered the current generation of the grid, skip // If React hasn't yet rendered the current generation of the grid, skip
// the update, because grid and slotRects will be out of sync // 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(
const tileRects = new Map<TileDescriptor<unknown>, Rect>( zip(orderedItems, slotRects) as [TileDescriptor<T>, Rect][]
zipWith(tileCells, slotRects, (cell, rect) => [cell.item, 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 })); 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 // Drag state is stored in a ref rather than component state, because we use
@ -249,8 +169,8 @@ export function NewVideoGrid<T>({
const [tileTransitions, springRef] = useTransition( const [tileTransitions, springRef] = useTransition(
tiles, tiles,
() => ({ () => ({
key: ({ item }: Tile) => item.id, key: ({ item }: Tile<T>) => item.id,
from: ({ x, y, width, height }: Tile) => ({ from: ({ x, y, width, height }: Tile<T>) => ({
opacity: 0, opacity: 0,
scale: 0, scale: 0,
shadow: 1, shadow: 1,
@ -263,7 +183,7 @@ export function NewVideoGrid<T>({
immediate: disableAnimations, immediate: disableAnimations,
}), }),
enter: { opacity: 1, scale: 1, immediate: disableAnimations }, enter: { opacity: 1, scale: 1, immediate: disableAnimations },
update: ({ item, x, y, width, height }: Tile) => update: ({ item, x, y, width, height }: Tile<T>) =>
item.id === dragState.current?.tileId item.id === dragState.current?.tileId
? null ? null
: { : {
@ -277,7 +197,7 @@ export function NewVideoGrid<T>({
config: { mass: 0.7, tension: 252, friction: 25 }, config: { mass: 0.7, tension: 252, friction: 25 },
}) })
// react-spring's types are bugged and can't infer the spring type // react-spring's types are bugged and can't infer the spring type
) as unknown as [TransitionFn<Tile, TileSpring>, SpringRef<TileSpring>]; ) as unknown as [TransitionFn<Tile<T>, TileSpring>, SpringRef<TileSpring>];
// Because we're using react-spring in imperative mode, we're responsible for // Because we're using react-spring in imperative mode, we're responsible for
// firing animations manually whenever the tiles array updates // firing animations manually whenever the tiles array updates
@ -288,11 +208,9 @@ export function NewVideoGrid<T>({
const animateDraggedTile = (endOfGesture: boolean) => { const animateDraggedTile = (endOfGesture: boolean) => {
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!; const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
const tile = tiles.find((t) => t.item.id === tileId)!; 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 springRef.current
.find((c) => (c.item as Tile).item.id === tileId) .find((c) => (c.item as Tile<T>).item.id === tileId)
?.start( ?.start(
endOfGesture endOfGesture
? { ? {
@ -320,36 +238,23 @@ export function NewVideoGrid<T>({
} }
); );
const columns = grid!.columns; const overTile = tiles.find(
const rows = row(grid!.cells.length - 1, grid!) + 1; (t) =>
cursorX >= t.x &&
const cursorColumn = Math.floor( cursorX < t.x + t.width &&
(cursorX / slotGrid!.clientWidth) * columns cursorY >= t.y &&
); cursorY < t.y + t.height
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 dest = if (overTile !== undefined)
Math.max( dragTile(
0, tile.item,
Math.min( overTile.item,
columns - originCell.columns, (cursorX - tileX) / tile.width,
cursorColumn - cursorColumnOnTile (cursorY - tileY) / tile.height,
) (cursorX - overTile.x) / overTile.width,
) + (cursorY - overTile.y) / overTile.height
grid!.columns * );
Math.max(
0,
Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile)
);
if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest));
}; };
// Callback for useDrag. We could call useDrag here, but the default // Callback for useDrag. We could call useDrag here, but the default
@ -367,29 +272,33 @@ export function NewVideoGrid<T>({
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0] }: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => { ) => {
if (tap) { if (tap) {
setGrid((g) => cycleTileSize(tileId, g!)); toggleFocus?.(items.find((i) => i.id === tileId)!);
} else { } else {
const tileSpring = springRef.current const tileController = springRef.current.find(
.find((c) => (c.item as Tile).item.id === tileId)! (c) => (c.item as Tile<T>).item.id === tileId
.get(); )!;
if (dragState.current === null) { if (canDragTile((tileController.item as Tile<T>).item)) {
dragState.current = { if (dragState.current === null) {
tileId, const tileSpring = tileController.get();
tileX: tileSpring.x, dragState.current = {
tileY: tileSpring.y, tileId,
cursorX: initialX - gridBounds.x, tileX: tileSpring.x,
cursorY: initialY - gridBounds.y + scrollOffset.current, 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<T>({
{ target: gridRef2 } { 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<number | null>(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<ReactNode>(items.length);
for (let i = 0; i < items.length; i++)
slots[i] = (
<div className={styles.slot} key={i} style={{ gridArea: `s${i}` }} />
);
return slots;
}, [items.length]);
// Render nothing if the grid has yet to be generated // Render nothing if the grid has yet to be generated
if (grid === null) { if (grid === null) {
return <div ref={gridRef} className={styles.grid} />; return <div ref={gridRef} className={styles.grid} />;
@ -465,10 +328,9 @@ export function NewVideoGrid<T>({
return ( return (
<div ref={gridRef} className={styles.grid}> <div ref={gridRef} className={styles.grid}>
<div <div
style={slotGridStyle} ref={setSlotsRoot}
ref={setSlotGrid} className={styles.slots}
className={styles.slotGrid} data-generation={generation}
data-generation={grid.generation}
> >
{slots} {slots}
</div> </div>
@ -482,7 +344,7 @@ export function NewVideoGrid<T>({
data={tile.item.data} data={tile.item.data}
{...spring} {...spring}
> >
{children as (props: ChildrenProperties<unknown>) => ReactNode} {children as (props: ChildrenProperties<T>) => ReactNode}
</TileWrapper> </TileWrapper>
))} ))}
</div> </div>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { SpringValue, to } from "@react-spring/web"; import { SpringValue, to } from "@react-spring/web";
@ -47,7 +47,7 @@ interface Props<T> {
* A wrapper around a tile in a video grid. This component exists to decouple * A wrapper around a tile in a video grid. This component exists to decouple
* child components from the grid. * child components from the grid.
*/ */
export const TileWrapper: FC<Props<unknown>> = memo( export const TileWrapper = memo(
({ ({
id, id,
onDragRef, onDragRef,
@ -97,4 +97,7 @@ export const TileWrapper: FC<Props<unknown>> = 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 <T>(props: Props<T>) => JSX.Element;

View file

@ -42,6 +42,7 @@ import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer"
import styles from "./VideoGrid.module.css"; import styles from "./VideoGrid.module.css";
import { Layout } from "../room/GridLayoutMenu"; import { Layout } from "../room/GridLayoutMenu";
import { TileWrapper } from "./TileWrapper"; import { TileWrapper } from "./TileWrapper";
import { LayoutStatesMap } from "./Layout";
interface TilePosition { interface TilePosition {
x: number; x: number;
@ -817,6 +818,7 @@ export interface VideoGridProps<T> {
items: TileDescriptor<T>[]; items: TileDescriptor<T>[];
layout: Layout; layout: Layout;
disableAnimations: boolean; disableAnimations: boolean;
layoutStates: LayoutStatesMap;
children: (props: ChildrenProperties<T>) => React.ReactNode; children: (props: ChildrenProperties<T>) => React.ReactNode;
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { useCallback } from "react";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next"; 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 MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg"; import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { FullscreenButton } from "../button/Button";
export interface ItemData { export interface ItemData {
id: string;
member?: RoomMember; member?: RoomMember;
sfuParticipant: LocalParticipant | RemoteParticipant; sfuParticipant: LocalParticipant | RemoteParticipant;
content: TileContent; content: TileContent;
@ -48,7 +50,9 @@ export enum TileContent {
interface Props { interface Props {
data: ItemData; data: ItemData;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
// TODO: Refactor these props. // TODO: Refactor these props.
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
@ -62,6 +66,9 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
( (
{ {
data, data,
maximised,
fullscreen,
onToggleFullscreen,
className, className,
style, style,
targetWidth, targetWidth,
@ -93,17 +100,33 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
} }
}, [member, setDisplayName]); }, [member, setDisplayName]);
const audioEl = React.useRef<HTMLAudioElement>(null);
const { isMuted: microphoneMuted } = useMediaTrack( const { isMuted: microphoneMuted } = useMediaTrack(
content === TileContent.UserMedia content === TileContent.UserMedia
? Track.Source.Microphone ? Track.Source.Microphone
: Track.Source.ScreenShareAudio, : Track.Source.ScreenShareAudio,
sfuParticipant, sfuParticipant
{
element: audioEl,
}
); );
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(
<FullscreenButton
key="fullscreen"
className={styles.button}
fullscreen={fullscreen}
onPress={onFullscreen}
/>
);
}
}
// Firefox doesn't respect the disablePictureInPicture attribute // Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831 // https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
@ -117,11 +140,15 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
showSpeakingIndicator, showSpeakingIndicator,
[styles.muted]: microphoneMuted, [styles.muted]: microphoneMuted,
[styles.screenshare]: content === TileContent.ScreenShare, [styles.screenshare]: content === TileContent.ScreenShare,
[styles.maximised]: maximised,
})} })}
style={style} style={style}
ref={tileRef} ref={tileRef}
data-testid="videoTile" data-testid="videoTile"
> >
{toolbarButtons.length > 0 && (!maximised || fullscreen) && (
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
)}
{content === TileContent.UserMedia && !sfuParticipant.isCameraEnabled && ( {content === TileContent.UserMedia && !sfuParticipant.isCameraEnabled && (
<> <>
<div className={styles.videoMutedOverlay} /> <div className={styles.videoMutedOverlay} />
@ -134,7 +161,7 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
/> />
</> </>
)} )}
{content == TileContent.ScreenShare ? ( {content === TileContent.ScreenShare ? (
<div className={styles.presenterLabel}> <div className={styles.presenterLabel}>
<span>{t("{{displayName}} is presenting", { displayName })}</span> <span>{t("{{displayName}} is presenting", { displayName })}</span>
</div> </div>
@ -155,7 +182,6 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
: Track.Source.ScreenShare : Track.Source.ScreenShare
} }
/> />
<audio ref={audioEl} />
</animated.div> </animated.div>
); );
} }

View file

@ -20,23 +20,23 @@ import {
cycleTileSize, cycleTileSize,
fillGaps, fillGaps,
forEachCellInArea, forEachCellInArea,
Grid, BigGridState,
resize, resize,
row, row,
tryMoveTile, moveTile,
} from "../../src/video-grid/model"; } from "../../src/video-grid/BigGrid";
import { TileDescriptor } from "../../src/video-grid/VideoGrid"; import { TileDescriptor } from "../../src/video-grid/VideoGrid";
/** /**
* Builds a grid from a string specifying the contents of each cell as a letter. * Builds a grid from a string specifying the contents of each cell as a letter.
*/ */
function mkGrid(spec: string): Grid { function mkGrid(spec: string): BigGridState {
const secondNewline = spec.indexOf("\n", 1); const secondNewline = spec.indexOf("\n", 1);
const columns = secondNewline === -1 ? spec.length : secondNewline - 1; const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]); const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
const areas = new Set(cells); const areas = new Set(cells);
areas.delete(" "); // Space represents an empty cell, not an area areas.delete(" "); // Space represents an empty cell, not an area
const grid: Grid = { columns, cells: new Array(cells.length) }; const grid: BigGridState = { columns, cells: new Array(cells.length) };
for (const area of areas) { for (const area of areas) {
const start = cells.indexOf(area); const start = cells.indexOf(area);
@ -60,12 +60,12 @@ function mkGrid(spec: string): Grid {
/** /**
* Turns a grid into a string showing the contents of each cell as a letter. * Turns a grid into a string showing the contents of each cell as a letter.
*/ */
function showGrid(g: Grid): string { function showGrid(g: BigGridState): string {
let result = "\n"; let result = "\n";
g.cells.forEach((c, i) => { for (let i = 0; i < g.cells.length; i++) {
if (i > 0 && i % g.columns == 0) result += "\n"; if (i > 0 && i % g.columns == 0) result += "\n";
result += c?.item.id ?? " "; result += g.cells[i]?.item.id ?? " ";
}); }
return result; return result;
} }
@ -222,21 +222,12 @@ function testCycleTileSize(
output: string output: string
): void { ): void {
test(`cycleTileSize ${title}`, () => { test(`cycleTileSize ${title}`, () => {
expect(showGrid(cycleTileSize(tileId, mkGrid(input)))).toBe(output); const grid = mkGrid(input);
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
expect(showGrid(cycleTileSize(grid, tile))).toBe(output);
}); });
} }
testCycleTileSize(
"does nothing if the tile is not present",
"z",
`
abcd
efgh`,
`
abcd
efgh`
);
testCycleTileSize( testCycleTileSize(
"expands a tile to 2×2 in a 3 column layout", "expands a tile to 2×2 in a 3 column layout",
"c", "c",
@ -345,8 +336,8 @@ abc
def`, def`,
` `
abc abc
gfe g
d` def`
); );
testAddItems( testAddItems(
@ -362,19 +353,19 @@ gge
d` d`
); );
function testTryMoveTile( function testMoveTile(
title: string, title: string,
from: number, from: number,
to: number, to: number,
input: string, input: string,
output: string output: string
): void { ): void {
test(`tryMoveTile ${title}`, () => { test(`moveTile ${title}`, () => {
expect(showGrid(tryMoveTile(mkGrid(input), from, to))).toBe(output); expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output);
}); });
} }
testTryMoveTile( testMoveTile(
"refuses to move a tile too far to the left", "refuses to move a tile too far to the left",
1, 1,
-1, -1,
@ -384,7 +375,7 @@ abc`,
abc` abc`
); );
testTryMoveTile( testMoveTile(
"refuses to move a tile too far to the right", "refuses to move a tile too far to the right",
1, 1,
3, 3,
@ -394,7 +385,7 @@ abc`,
abc` abc`
); );
testTryMoveTile( testMoveTile(
"moves a large tile to an unoccupied space", "moves a large tile to an unoccupied space",
3, 3,
1, 1,
@ -408,7 +399,7 @@ bcc
d e` d e`
); );
testTryMoveTile( testMoveTile(
"refuses to move a large tile to an occupied space", "refuses to move a large tile to an occupied space",
3, 3,
1, 1,