Merge remote-tracking branch 'origin/livekit' into dbkr/openid
This commit is contained in:
commit
a0b342069d
30 changed files with 771 additions and 373 deletions
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
|
@ -2,7 +2,7 @@ name: Build
|
|||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [livekit]
|
||||
branches: [livekit, full-mesh]
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
|
|
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
|
@ -1,6 +1,8 @@
|
|||
name: Run jest tests
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [livekit, full-mesh]
|
||||
jobs:
|
||||
jest:
|
||||
name: Run jest tests
|
||||
|
@ -16,3 +18,7 @@ jobs:
|
|||
run: "yarn install"
|
||||
- name: Jest
|
||||
run: "yarn run test"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
flags: unittests
|
||||
|
|
|
@ -122,6 +122,11 @@
|
|||
"\\.(css|less|svg)+$": "identity-obj-proxy",
|
||||
"^\\./IndexedDBWorker\\?worker$": "<rootDir>/test/mocks/workerMock.ts",
|
||||
"^\\./olm$": "<rootDir>/test/mocks/olmMock.ts"
|
||||
}
|
||||
},
|
||||
"collectCoverage": true,
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"cobertura"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>",
|
||||
"Password": "Парола",
|
||||
"Passwords must match": "Паролите не съвпадат",
|
||||
"Press and hold to talk over {{name}}": "Натиснете и задръжте за да говорите заедно с {{name}}",
|
||||
"Profile": "Профил",
|
||||
"Recaptcha dismissed": "Recaptcha отхвърлена",
|
||||
"Recaptcha not loaded": "Recaptcha не е заредена",
|
||||
|
|
|
@ -62,7 +62,6 @@
|
|||
"Inspector": "Insepktor",
|
||||
"Incompatible versions!": "Nekompatibilní verze!",
|
||||
"Incompatible versions": "Nekompatibilní verze",
|
||||
"{{count}} people connected|other": "{{count}} lidí připojeno",
|
||||
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</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": "Vysílačkový hovor",
|
||||
|
|
|
@ -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>",
|
||||
"Password": "Passwort",
|
||||
"Passwords must match": "Passwörter müssen übereinstimmen",
|
||||
"Press and hold to talk over {{name}}": "Zum Verdrängen von {{name}} und Sprechen gedrückt halten",
|
||||
"Profile": "Profil",
|
||||
"Recaptcha dismissed": "Recaptcha abgelehnt",
|
||||
"Recaptcha not loaded": "Recaptcha nicht geladen",
|
||||
|
|
|
@ -71,6 +71,5 @@
|
|||
"Close": "Κλείσιμο",
|
||||
"Change layout": "Αλλαγή διάταξης",
|
||||
"Camera": "Κάμερα",
|
||||
"Audio": "Ήχος",
|
||||
"{{name}} (Connecting...)": "{{name}} (Συνδέεται...)"
|
||||
"Audio": "Ήχος"
|
||||
}
|
||||
|
|
|
@ -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>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>",
|
||||
"{{name}} (Connecting...)": "{{name}} (Conectando...)",
|
||||
"Element Call Home": "Inicio de Element Call",
|
||||
"Copy": "Copiar",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.</0>",
|
||||
|
|
|
@ -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>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}}",
|
||||
"{{count}} people connected|other": "{{count}} osalejat liitunud",
|
||||
"Invite people": "Kutsu inimesi",
|
||||
"Invite": "Kutsu",
|
||||
"Inspector": "Inspektor",
|
||||
|
@ -114,5 +113,7 @@
|
|||
"How did it go?": "Kuidas sujus?",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, sinu kõne on lõppenud.",
|
||||
"<0>Thanks for your feedback!</0>": "<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"
|
||||
}
|
||||
|
|
|
@ -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>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>",
|
||||
"{{count}} people connected|other": "{{count}} نفر متصل هستند",
|
||||
"Local volume": "حجم داخلی",
|
||||
"Inspector": "بازرس",
|
||||
"Incompatible versions!": "نسخههای ناسازگار!",
|
||||
|
@ -72,7 +71,6 @@
|
|||
"Register": "ثبتنام",
|
||||
"Recaptcha not loaded": "کپچا بارگیری نشد",
|
||||
"Recaptcha dismissed": "ریکپچا رد شد",
|
||||
"Press and hold spacebar to talk": "برای صحبت کردن کلید فاصله را فشار داده و نگه دارید",
|
||||
"Passwords must match": "رمز عبور باید همخوانی داشته باشد",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "کاربران دیگر تلاش میکنند با ورژنهای ناسازگار به مکالمه بپیوندند. این کاربران باید از بروزرسانی مرورگرشان اطمینان داشته باشند:<1>{userLis}</1>",
|
||||
"Not registered yet? <2>Create an account</2>": "هنوز ثبتنام نکردهاید؟ <2>ساخت حساب کاربری</2>",
|
||||
|
|
|
@ -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>",
|
||||
"Password": "Mot de passe",
|
||||
"Passwords must match": "Les mots de passe doivent correspondre",
|
||||
"Press and hold to talk over {{name}}": "Appuyez et maintenez enfoncé pour parler par dessus {{name}}",
|
||||
"Profile": "Profil",
|
||||
"Recaptcha dismissed": "Recaptcha refusé",
|
||||
"Recaptcha not loaded": "Recaptcha non chargé",
|
||||
|
@ -114,5 +113,7 @@
|
|||
"{{count}} stars|one": "{{count}} favori",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, votre appel est terminé.",
|
||||
"<0>Thanks for your feedback!</0>": "<0>Merci pour votre commentaire !</0>",
|
||||
"How did it go?": "Comment cela s’est-il passé ?"
|
||||
"How did it go?": "Comment cela s’est-il passé ?",
|
||||
"{{displayName}} is presenting": "{{displayName}} est à l’écran",
|
||||
"Show connection stats": "Afficher les statistiques de la connexion"
|
||||
}
|
||||
|
|
|
@ -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>",
|
||||
"Password": "Kata sandi",
|
||||
"Passwords must match": "Kata sandi harus cocok",
|
||||
"Press and hold to talk over {{name}}": "Tekan dan tahan untuk berbicara pada {{name}}",
|
||||
"Profile": "Profil",
|
||||
"Recaptcha dismissed": "Recaptcha ditutup",
|
||||
"Recaptcha not loaded": "Recaptcha tidak dimuat",
|
||||
|
@ -113,5 +112,8 @@
|
|||
"<0>Thanks for your feedback!</0>": "<0>Terima kasih atas masukan Anda!</0>",
|
||||
"How did it go?": "Bagaimana rasanya?",
|
||||
"{{count}} stars|one": "{{count}} bintang",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<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"
|
||||
}
|
||||
|
|
|
@ -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>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>",
|
||||
|
|
|
@ -40,7 +40,6 @@
|
|||
"Recaptcha not loaded": "Recaptcha nie została załadowana",
|
||||
"Recaptcha dismissed": "Recaptcha odrzucona",
|
||||
"Profile": "Profil",
|
||||
"Press and hold spacebar to talk": "Przytrzymaj spację, aby mówić",
|
||||
"Passwords must match": "Hasła muszą pasować",
|
||||
"Password": "Hasło",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</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>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}}",
|
||||
"This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.",
|
||||
"Copy": "Kopiuj",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<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>",
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
"Submit feedback": "Отправить отзыв",
|
||||
"Sending debug logs…": "Отправка журнала отладки…",
|
||||
"Select an option": "Выберите вариант",
|
||||
"Press and hold spacebar to talk over {{name}}": "Чтобы говорить поверх участника {{name}}, нажмите и удерживайте [Пробел]",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Другие пользователи пытаются присоединиться с неподдерживаемых версий программы. Этим участникам надо перезагрузить браузер: <1>{userLis}</1>",
|
||||
"Grid layout menu": "Меню \"Расположение сеткой\"",
|
||||
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Нажимая \"Присоединиться сейчас\", вы соглашаетесь с нашими <2>положениями и условиями</2>",
|
||||
|
@ -94,7 +93,6 @@
|
|||
"Call link copied": "Ссылка на звонок скопирована",
|
||||
"Avatar": "Аватар",
|
||||
"Audio": "Аудио",
|
||||
"{{name}} is presenting": "{{name}} показывает",
|
||||
"Element Call Home": "Главная Element Call",
|
||||
"Copy": "Копировать",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Присоединиться сейчас к звонку</0><1>или<1><2>Скопировать ссылку на звонок и присоединиться позже</2>",
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
"Recaptcha not loaded": "Recaptcha sa nenačítala",
|
||||
"Recaptcha dismissed": "Recaptcha zamietnutá",
|
||||
"Profile": "Profil",
|
||||
"Press and hold spacebar to talk": "Stlačte a podržte medzerník, ak chcete hovoriť",
|
||||
"Passwords must match": "Heslá sa musia zhodovať",
|
||||
"Password": "Heslo",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</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>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}}",
|
||||
"{{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>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í.",
|
||||
|
@ -115,5 +113,7 @@
|
|||
"{{count}} stars|other": "{{count}} hviezdičiek",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, váš hovor skončil.",
|
||||
"<0>Thanks for your feedback!</0>": "<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"
|
||||
}
|
||||
|
|
|
@ -39,7 +39,6 @@
|
|||
"Recaptcha not loaded": "Recaptcha не завантажено",
|
||||
"Recaptcha dismissed": "Recaptcha не пройдено",
|
||||
"Profile": "Профіль",
|
||||
"Press and hold spacebar to talk": "Затисніть пробіл, щоб говорити",
|
||||
"Passwords must match": "Паролі відрізняються",
|
||||
"Password": "Пароль",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Інші користувачі намагаються приєднатися до цього виклику з несумісних версій. Ці користувачі повинні переконатися, що вони оновили сторінки своїх браузерів:<1>{userLis}</1>",
|
||||
|
@ -94,7 +93,6 @@
|
|||
"<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>",
|
||||
"{{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>",
|
||||
"Element Call Home": "Домівка Element Call",
|
||||
"Copy": "Копіювати",
|
||||
|
@ -115,5 +113,7 @@
|
|||
"{{count}} stars|other": "{{count}} зірок",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, ваш виклик завершено.",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Ми будемо раді почути ваші відгуки, щоб поліпшити роботу застосунку.</0>",
|
||||
"How did it go?": "Вам усе сподобалось?"
|
||||
"How did it go?": "Вам усе сподобалось?",
|
||||
"{{displayName}} is presenting": "{{displayName}} представляє",
|
||||
"Show connection stats": "Показати стан з'єднання"
|
||||
}
|
||||
|
|
|
@ -31,7 +31,6 @@
|
|||
"<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>",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} (Connecting...)": "{{name}} (正在连接……)",
|
||||
"Inspector": "检查器",
|
||||
"Show call inspector": "显示通话检查器",
|
||||
"Share screen": "屏幕共享",
|
||||
|
@ -47,7 +46,6 @@
|
|||
"Recaptcha not loaded": "reCaptcha未加载",
|
||||
"Recaptcha dismissed": "reCaptcha验证失败",
|
||||
"Profile": "个人信息",
|
||||
"Press and hold spacebar to talk": "按住空格键发言",
|
||||
"Passwords must match": "密码必须匹配",
|
||||
"Password": "密码",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "其他用户正试图从不兼容的版本加入这一呼叫。这些用户应该确保已经刷新了浏览器:<1>{userLis}</1>",
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
"<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>",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} (Connecting...)": "{{name}} (連結中...)",
|
||||
"Expose developer settings in the settings window.": "在設定視窗中顯示開發者設定。",
|
||||
"Developer Settings": "開發者設定",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>送出除錯紀錄,可幫助我們修正問題。</0>",
|
||||
|
@ -47,7 +46,6 @@
|
|||
"Recaptcha not loaded": "驗證碼未載入",
|
||||
"Recaptcha dismissed": "略過驗證碼",
|
||||
"Profile": "個人檔案",
|
||||
"Press and hold spacebar to talk": "說話時請按住空白鍵",
|
||||
"Passwords must match": "密碼必須相符",
|
||||
"Password": "密碼",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "有使用者試著加入通話,但他們的軟體版本不相容。這些使用者需要確認已將瀏覽器更新到最新版本:<1>{userLis}</1>",
|
||||
|
@ -115,5 +113,7 @@
|
|||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>我們想要聽到您的回饋,如此我們才能改善您的體驗。</0>",
|
||||
"{{count}} stars|one": "{{count}} 個星星",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}},您的通話已結束。",
|
||||
"How did it go?": "進展如何?"
|
||||
"How did it go?": "進展如何?",
|
||||
"{{displayName}} is presenting": "{{displayName}} 正在展示",
|
||||
"Show connection stats": "顯示連線統計資料"
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ limitations under the License.
|
|||
|
||||
import { ResizeObserver } from "@juggle/resize-observer";
|
||||
import {
|
||||
RoomAudioRenderer,
|
||||
RoomContext,
|
||||
useLocalParticipant,
|
||||
useParticipants,
|
||||
useTracks,
|
||||
|
@ -80,6 +82,8 @@ import { VideoTile } from "../video-grid/VideoTile";
|
|||
import { UserChoices, useLiveKit } from "../livekit/useLiveKit";
|
||||
import { useMediaDevices } from "../livekit/useMediaDevices";
|
||||
import { SFUConfig } from "../livekit/openIDSFU";
|
||||
import { useFullscreen } from "./useFullscreen";
|
||||
import { useLayoutStates } from "../video-grid/Layout";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||
|
@ -95,7 +99,13 @@ export interface ActiveCallProps extends Omit<InCallViewProps, "livekitRoom"> {
|
|||
export function ActiveCall(props: ActiveCallProps) {
|
||||
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 {
|
||||
|
@ -167,9 +177,6 @@ export function InCallView({
|
|||
const toggleCamera = useCallback(async () => {
|
||||
await localParticipant.setCameraEnabled(!isCameraEnabled);
|
||||
}, [localParticipant, isCameraEnabled]);
|
||||
const toggleScreensharing = useCallback(async () => {
|
||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled);
|
||||
}, [localParticipant, isScreenShareEnabled]);
|
||||
|
||||
const joinRule = useJoinRule(groupCall.room);
|
||||
|
||||
|
@ -222,15 +229,19 @@ export function InCallView({
|
|||
const noControls = reducedControls && bounds.height <= 400;
|
||||
|
||||
const items = useParticipantTiles(livekitRoom, participants);
|
||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
||||
useFullscreen(items);
|
||||
|
||||
// The maximised participant is the focused (active) participant, given the
|
||||
// The maximised participant: either the participant that the user has
|
||||
// manually put in fullscreen, or the focused (active) participant if the
|
||||
// window is too small to show everyone
|
||||
const maximisedParticipant = useMemo(
|
||||
() =>
|
||||
noControls
|
||||
? items.find((item) => item.focused) ?? items.at(0) ?? null
|
||||
: null,
|
||||
[noControls, items]
|
||||
fullscreenItem ??
|
||||
(noControls
|
||||
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
|
||||
: null),
|
||||
[fullscreenItem, noControls, items]
|
||||
);
|
||||
|
||||
const Grid =
|
||||
|
@ -238,6 +249,10 @@ export function InCallView({
|
|||
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
// This state is lifted out of NewVideoGrid so that layout states can be
|
||||
// restored after a layout switch or upon exiting fullscreen
|
||||
const layoutStates = useLayoutStates();
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
|
@ -249,6 +264,9 @@ export function InCallView({
|
|||
if (maximisedParticipant) {
|
||||
return (
|
||||
<VideoTile
|
||||
maximised={true}
|
||||
fullscreen={maximisedParticipant === fullscreenItem}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
targetHeight={bounds.height}
|
||||
targetWidth={bounds.width}
|
||||
key={maximisedParticipant.id}
|
||||
|
@ -264,9 +282,13 @@ export function InCallView({
|
|||
items={items}
|
||||
layout={layout}
|
||||
disableAnimations={prefersReducedMotion || isSafari}
|
||||
layoutStates={layoutStates}
|
||||
>
|
||||
{(props) => (
|
||||
<VideoTile
|
||||
maximised={false}
|
||||
fullscreen={false}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
showSpeakingIndicator={items.length > 2}
|
||||
showConnectionStats={showConnectionStats}
|
||||
{...props}
|
||||
|
@ -316,6 +338,11 @@ export function InCallView({
|
|||
[styles.maximised]: undefined,
|
||||
});
|
||||
|
||||
const toggleScreensharing = useCallback(async () => {
|
||||
exitFullscreen();
|
||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled);
|
||||
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
|
||||
|
||||
let footer: JSX.Element | null;
|
||||
|
||||
if (noControls) {
|
||||
|
@ -349,9 +376,7 @@ export function InCallView({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (!maximisedParticipant) {
|
||||
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
|
||||
}
|
||||
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
|
@ -362,7 +387,7 @@ export function InCallView({
|
|||
|
||||
return (
|
||||
<div className={containerClasses} ref={containerRef}>
|
||||
{!hideHeader && (
|
||||
{!hideHeader && maximisedParticipant === null && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
|
@ -383,6 +408,7 @@ export function InCallView({
|
|||
</Header>
|
||||
)}
|
||||
<div className={styles.controlsOverlay}>
|
||||
<RoomAudioRenderer />
|
||||
{renderContent()}
|
||||
{footer}
|
||||
</div>
|
||||
|
@ -464,6 +490,7 @@ function useParticipantTiles(
|
|||
local: sfuParticipant.isLocal,
|
||||
largeBaseSize: false,
|
||||
data: {
|
||||
id,
|
||||
member,
|
||||
sfuParticipant,
|
||||
content: TileContent.UserMedia,
|
||||
|
@ -473,14 +500,16 @@ function useParticipantTiles(
|
|||
// If there is a screen sharing enabled for this participant, create a tile for it as well.
|
||||
let screenShareTile: TileDescriptor<ItemData> | undefined;
|
||||
if (sfuParticipant.isScreenShareEnabled) {
|
||||
const screenShareId = `${id}:screen-share`;
|
||||
screenShareTile = {
|
||||
...userMediaTile,
|
||||
id: `${id}:screen-share`,
|
||||
id: screenShareId,
|
||||
focused: true,
|
||||
largeBaseSize: true,
|
||||
placeNear: id,
|
||||
data: {
|
||||
...userMediaTile.data,
|
||||
id: screenShareId,
|
||||
content: TileContent.ScreenShare,
|
||||
},
|
||||
};
|
||||
|
|
114
src/room/useFullscreen.ts
Normal file
114
src/room/useFullscreen.ts
Normal 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,
|
||||
};
|
||||
}
|
29
src/video-grid/BigGrid.module.css
Normal file
29
src/video-grid/BigGrid.module.css
Normal 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;
|
||||
}
|
||||
}
|
|
@ -15,33 +15,48 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import TinyQueue from "tinyqueue";
|
||||
import { RectReadOnly } from "react-use-measure";
|
||||
import { FC, memo, ReactNode } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { TileDescriptor } from "./VideoGrid";
|
||||
import { Slot } from "./NewVideoGrid";
|
||||
import { Layout } from "./Layout";
|
||||
import { count, findLastIndex } from "../array-utils";
|
||||
import styles from "./BigGrid.module.css";
|
||||
|
||||
/**
|
||||
* A 1×1 cell in a grid which belongs to a tile.
|
||||
*/
|
||||
export interface Cell {
|
||||
interface Cell {
|
||||
/**
|
||||
* The item displayed on the tile.
|
||||
*/
|
||||
item: TileDescriptor<unknown>;
|
||||
readonly item: TileDescriptor<unknown>;
|
||||
/**
|
||||
* Whether this cell is the origin (top left corner) of the tile.
|
||||
*/
|
||||
origin: boolean;
|
||||
readonly origin: boolean;
|
||||
/**
|
||||
* The width, in columns, of the tile.
|
||||
*/
|
||||
columns: number;
|
||||
readonly columns: number;
|
||||
/**
|
||||
* The height, in rows, of the tile.
|
||||
*/
|
||||
rows: number;
|
||||
readonly rows: number;
|
||||
}
|
||||
|
||||
export interface Grid {
|
||||
export interface BigGridState {
|
||||
readonly columns: number;
|
||||
/**
|
||||
* The cells of the grid, in left-to-right top-to-bottom order.
|
||||
* undefined = empty.
|
||||
*/
|
||||
readonly cells: (Cell | undefined)[];
|
||||
}
|
||||
|
||||
interface MutableBigGridState {
|
||||
columns: number;
|
||||
/**
|
||||
* The cells of the grid, in left-to-right top-to-bottom order.
|
||||
|
@ -58,7 +73,7 @@ export interface Grid {
|
|||
* @returns An array in which each cell holds the index of the next cell to move
|
||||
* to to reach the destination, or null if it is the destination.
|
||||
*/
|
||||
export function getPaths(dest: number, g: Grid): (number | null)[] {
|
||||
export function getPaths(dest: number, g: BigGridState): (number | null)[] {
|
||||
const destRow = row(dest, g);
|
||||
const destColumn = column(dest, g);
|
||||
|
||||
|
@ -106,18 +121,23 @@ export function getPaths(dest: number, g: Grid): (number | null)[] {
|
|||
return edges as (number | null)[];
|
||||
}
|
||||
|
||||
const findLast1By1Index = (g: Grid): number | null =>
|
||||
const findLast1By1Index = (g: BigGridState): number | null =>
|
||||
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
|
||||
|
||||
export function row(index: number, g: Grid): number {
|
||||
export function row(index: number, g: BigGridState): number {
|
||||
return Math.floor(index / g.columns);
|
||||
}
|
||||
|
||||
export function column(index: number, g: Grid): number {
|
||||
export function column(index: number, g: BigGridState): number {
|
||||
return ((index % g.columns) + g.columns) % g.columns;
|
||||
}
|
||||
|
||||
function inArea(index: number, start: number, end: number, g: Grid): boolean {
|
||||
function inArea(
|
||||
index: number,
|
||||
start: number,
|
||||
end: number,
|
||||
g: BigGridState
|
||||
): boolean {
|
||||
const indexColumn = column(index, g);
|
||||
const indexRow = row(index, g);
|
||||
return (
|
||||
|
@ -131,7 +151,7 @@ function inArea(index: number, start: number, end: number, g: Grid): boolean {
|
|||
function* cellsInArea(
|
||||
start: number,
|
||||
end: number,
|
||||
g: Grid
|
||||
g: BigGridState
|
||||
): Generator<number, void, unknown> {
|
||||
const startColumn = column(start, g);
|
||||
const endColumn = column(end, g);
|
||||
|
@ -149,7 +169,7 @@ function* cellsInArea(
|
|||
export function forEachCellInArea(
|
||||
start: number,
|
||||
end: number,
|
||||
g: Grid,
|
||||
g: BigGridState,
|
||||
fn: (c: Cell | undefined, i: number) => void
|
||||
): void {
|
||||
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
|
||||
|
@ -158,7 +178,7 @@ export function forEachCellInArea(
|
|||
function allCellsInArea(
|
||||
start: number,
|
||||
end: number,
|
||||
g: Grid,
|
||||
g: BigGridState,
|
||||
fn: (c: Cell | undefined, i: number) => boolean
|
||||
): boolean {
|
||||
for (const i of cellsInArea(start, end, g)) {
|
||||
|
@ -172,16 +192,19 @@ const areaEnd = (
|
|||
start: number,
|
||||
columns: number,
|
||||
rows: number,
|
||||
g: Grid
|
||||
g: BigGridState
|
||||
): number => start + columns - 1 + g.columns * (rows - 1);
|
||||
|
||||
const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] });
|
||||
const cloneGrid = (g: BigGridState): BigGridState => ({
|
||||
...g,
|
||||
cells: [...g.cells],
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets the index of the next gap in the grid that should be backfilled by 1×1
|
||||
* tiles.
|
||||
*/
|
||||
function getNextGap(g: Grid): number | null {
|
||||
function getNextGap(g: BigGridState): number | null {
|
||||
const last1By1Index = findLast1By1Index(g);
|
||||
if (last1By1Index === null) return null;
|
||||
|
||||
|
@ -204,7 +227,7 @@ function getNextGap(g: Grid): number | null {
|
|||
/**
|
||||
* Gets the index of the origin of the tile to which the given cell belongs.
|
||||
*/
|
||||
function getOrigin(g: Grid, index: number): number {
|
||||
function getOrigin(g: BigGridState, index: number): number {
|
||||
const initialColumn = column(index, g);
|
||||
|
||||
for (
|
||||
|
@ -229,7 +252,7 @@ function getOrigin(g: Grid, index: number): number {
|
|||
* along the way.
|
||||
* Precondition: the destination area must consist of only 1×1 tiles.
|
||||
*/
|
||||
function moveTile(g: Grid, from: number, to: number) {
|
||||
function moveTileUnchecked(g: BigGridState, from: number, to: number) {
|
||||
const tile = g.cells[from]!;
|
||||
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
|
||||
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
|
||||
|
@ -262,10 +285,15 @@ function moveTile(g: Grid, from: number, to: number) {
|
|||
/**
|
||||
* Moves the tile at index "from" over to index "to", if there is space.
|
||||
*/
|
||||
export function tryMoveTile(g: Grid, from: number, to: number): Grid {
|
||||
export function moveTile(
|
||||
g: BigGridState,
|
||||
from: number,
|
||||
to: number
|
||||
): BigGridState {
|
||||
const tile = g.cells[from]!;
|
||||
|
||||
if (
|
||||
to !== from && // Skip the operation if nothing would move
|
||||
to >= 0 &&
|
||||
to < g.cells.length &&
|
||||
column(to, g) <= g.columns - tile.columns
|
||||
|
@ -283,7 +311,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
|
|||
if (allCellsInArea(to, toEnd, g, displaceable)) {
|
||||
// The target space is free; move
|
||||
const gClone = cloneGrid(g);
|
||||
moveTile(gClone, from, to);
|
||||
moveTileUnchecked(gClone, from, to);
|
||||
return gClone;
|
||||
}
|
||||
}
|
||||
|
@ -297,7 +325,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
|
|||
* enlarged tiles around when necessary.
|
||||
* @returns Whether the tile was actually pushed
|
||||
*/
|
||||
function pushTileUp(g: Grid, from: number): boolean {
|
||||
function pushTileUp(g: BigGridState, from: number): boolean {
|
||||
const tile = g.cells[from]!;
|
||||
|
||||
// TODO: pushing large tiles sideways might be more successful in some
|
||||
|
@ -315,7 +343,7 @@ function pushTileUp(g: Grid, from: number): boolean {
|
|||
);
|
||||
|
||||
if (cellsAboveAreDisplacable) {
|
||||
moveTile(g, from, from - g.columns);
|
||||
moveTileUnchecked(g, from, from - g.columns);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
|
@ -325,8 +353,8 @@ function pushTileUp(g: Grid, from: number): boolean {
|
|||
/**
|
||||
* Backfill any gaps in the grid.
|
||||
*/
|
||||
export function fillGaps(g: Grid): Grid {
|
||||
const result = cloneGrid(g);
|
||||
export function fillGaps(g: BigGridState): BigGridState {
|
||||
const result = cloneGrid(g) as MutableBigGridState;
|
||||
|
||||
// This will hopefully be the size of the grid after we're done here, assuming
|
||||
// that we can pack the large tiles tightly enough
|
||||
|
@ -403,7 +431,11 @@ export function fillGaps(g: Grid): Grid {
|
|||
return result;
|
||||
}
|
||||
|
||||
function createRows(g: Grid, count: number, atRow: number): Grid {
|
||||
function createRows(
|
||||
g: BigGridState,
|
||||
count: number,
|
||||
atRow: number
|
||||
): BigGridState {
|
||||
const result = {
|
||||
columns: g.columns,
|
||||
cells: new Array(g.cells.length + g.columns * count),
|
||||
|
@ -430,9 +462,12 @@ function createRows(g: Grid, count: number, atRow: number): Grid {
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds a set of new items into the grid.
|
||||
* Adds a set of new items into the grid. (May leave gaps.)
|
||||
*/
|
||||
export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
||||
export function addItems(
|
||||
items: TileDescriptor<unknown>[],
|
||||
g: BigGridState
|
||||
): BigGridState {
|
||||
let result = cloneGrid(g);
|
||||
|
||||
for (const item of items) {
|
||||
|
@ -444,13 +479,11 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
|||
};
|
||||
|
||||
let placeAt: number;
|
||||
let hasGaps: boolean;
|
||||
|
||||
if (item.placeNear === undefined) {
|
||||
// This item has no special placement requests, so let's put it
|
||||
// uneventfully at the end of the grid
|
||||
placeAt = result.cells.length;
|
||||
hasGaps = false;
|
||||
} else {
|
||||
// This item wants to be placed near another; let's put it on a row
|
||||
// directly below the related tile
|
||||
|
@ -460,7 +493,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
|||
if (placeNear === -1) {
|
||||
// Can't find the related tile, so let's give up and place it at the end
|
||||
placeAt = result.cells.length;
|
||||
hasGaps = false;
|
||||
} else {
|
||||
const placeNearCell = result.cells[placeNear]!;
|
||||
const placeNearEnd = areaEnd(
|
||||
|
@ -475,7 +507,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
|||
placeNear +
|
||||
Math.floor(placeNearCell.columns / 2) +
|
||||
result.columns * placeNearCell.rows;
|
||||
hasGaps = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -484,21 +515,19 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
|||
if (item.largeBaseSize) {
|
||||
// Cycle the tile size once to set up the tile with its larger base size
|
||||
// This also fills any gaps in the grid, hence no extra call to fillGaps
|
||||
result = cycleTileSize(item.id, result);
|
||||
} else if (hasGaps) {
|
||||
result = fillGaps(result);
|
||||
result = cycleTileSize(result, item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const largeTileDimensions = (g: Grid): [number, number] => [
|
||||
const largeTileDimensions = (g: BigGridState): [number, number] => [
|
||||
Math.min(3, Math.max(2, g.columns - 1)),
|
||||
2,
|
||||
];
|
||||
|
||||
const extraLargeTileDimensions = (g: Grid): [number, number] =>
|
||||
const extraLargeTileDimensions = (g: BigGridState): [number, number] =>
|
||||
g.columns > 3 ? [4, 3] : [g.columns, 2];
|
||||
|
||||
/**
|
||||
|
@ -507,8 +536,11 @@ const extraLargeTileDimensions = (g: Grid): [number, number] =>
|
|||
* @param g The grid.
|
||||
* @returns The updated grid.
|
||||
*/
|
||||
export function cycleTileSize(tileId: string, g: Grid): Grid {
|
||||
const from = g.cells.findIndex((c) => c?.item.id === tileId);
|
||||
export function cycleTileSize(
|
||||
g: BigGridState,
|
||||
tile: TileDescriptor<unknown>
|
||||
): BigGridState {
|
||||
const from = g.cells.findIndex((c) => c?.item === tile);
|
||||
if (from === -1) return g; // Tile removed, no change
|
||||
const fromCell = g.cells[from]!;
|
||||
const fromWidth = fromCell.columns;
|
||||
|
@ -629,8 +661,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
|
|||
/**
|
||||
* Resizes the grid to a new column width.
|
||||
*/
|
||||
export function resize(g: Grid, columns: number): Grid {
|
||||
const result: Grid = { columns, cells: [] };
|
||||
export function resize(g: BigGridState, columns: number): BigGridState {
|
||||
const result: BigGridState = { columns, cells: [] };
|
||||
const [largeColumns, largeRows] = largeTileDimensions(result);
|
||||
|
||||
// Copy each tile from the old grid to the resized one in the same order
|
||||
|
@ -640,6 +672,7 @@ export function resize(g: Grid, columns: number): Grid {
|
|||
|
||||
for (const cell of g.cells) {
|
||||
if (cell?.origin) {
|
||||
// TODO make aware of extra large tiles
|
||||
const [nextColumns, nextRows] =
|
||||
cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1];
|
||||
|
||||
|
@ -672,7 +705,7 @@ export function resize(g: Grid, columns: number): Grid {
|
|||
/**
|
||||
* Promotes speakers to the first page of the grid.
|
||||
*/
|
||||
export function promoteSpeakers(g: Grid) {
|
||||
export function promoteSpeakers(g: BigGridState) {
|
||||
// This is all a bit of a hack right now, because we don't know if the designs
|
||||
// will stick with this approach in the long run
|
||||
// We assume that 4 rows are probably about 1 page
|
||||
|
@ -694,10 +727,149 @@ export function promoteSpeakers(g: Grid) {
|
|||
toCell === undefined ||
|
||||
(toCell.columns === 1 && toCell.rows === 1)
|
||||
) {
|
||||
moveTile(g, from, to);
|
||||
moveTileUnchecked(g, from, to);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The algorithm for updating a grid with a new set of tiles.
|
||||
*/
|
||||
function updateTiles(
|
||||
g: BigGridState,
|
||||
tiles: TileDescriptor<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
178
src/video-grid/Layout.tsx
Normal 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} />,
|
||||
};
|
||||
};
|
|
@ -23,11 +23,8 @@ limitations under the License.
|
|||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.slotGrid {
|
||||
.slots {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-auto-rows: 163px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
|
@ -38,10 +35,4 @@ limitations under the License.
|
|||
.grid {
|
||||
padding: 0 22px var(--footerHeight);
|
||||
}
|
||||
|
||||
.slotGrid {
|
||||
grid-auto-rows: 183px;
|
||||
column-gap: 18px;
|
||||
row-gap: 21px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,17 +17,16 @@ limitations under the License.
|
|||
import { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
|
||||
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
||||
import React, {
|
||||
Dispatch,
|
||||
CSSProperties,
|
||||
FC,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { zipWith } from "lodash";
|
||||
import { zip } from "lodash";
|
||||
|
||||
import styles from "./NewVideoGrid.module.css";
|
||||
import {
|
||||
|
@ -38,99 +37,9 @@ import {
|
|||
} from "./VideoGrid";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import {
|
||||
Grid,
|
||||
Cell,
|
||||
row,
|
||||
column,
|
||||
fillGaps,
|
||||
forEachCellInArea,
|
||||
cycleTileSize,
|
||||
addItems,
|
||||
tryMoveTile,
|
||||
resize,
|
||||
promoteSpeakers,
|
||||
} from "./model";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
|
||||
interface GridState extends Grid {
|
||||
/**
|
||||
* The ID of the current state of the grid.
|
||||
*/
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const useGridState = (
|
||||
columns: number | null,
|
||||
items: TileDescriptor<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];
|
||||
};
|
||||
import { BigGrid } from "./BigGrid";
|
||||
import { useLayout } from "./Layout";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
|
@ -139,8 +48,8 @@ interface Rect {
|
|||
height: number;
|
||||
}
|
||||
|
||||
interface Tile extends Rect {
|
||||
item: TileDescriptor<unknown>;
|
||||
interface Tile<T> extends Rect {
|
||||
item: TileDescriptor<T>;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
|
@ -151,12 +60,21 @@ interface DragState {
|
|||
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.
|
||||
*/
|
||||
export function NewVideoGrid<T>({
|
||||
items,
|
||||
disableAnimations,
|
||||
layoutStates,
|
||||
children,
|
||||
}: Props<T>) {
|
||||
// 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
|
||||
// MutationObserver.
|
||||
|
||||
const [slotGrid, setSlotGrid] = useState<HTMLDivElement | null>(null);
|
||||
const [slotGridGeneration, setSlotGridGeneration] = useState(0);
|
||||
const [slotsRoot, setSlotsRoot] = useState<HTMLDivElement | null>(null);
|
||||
const [renderedGeneration, setRenderedGeneration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (slotGrid !== null) {
|
||||
setSlotGridGeneration(
|
||||
parseInt(slotGrid.getAttribute("data-generation")!)
|
||||
if (slotsRoot !== null) {
|
||||
setRenderedGeneration(
|
||||
parseInt(slotsRoot.getAttribute("data-generation")!)
|
||||
);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((m) => m.type === "attributes")) {
|
||||
setSlotGridGeneration(
|
||||
parseInt(slotGrid.getAttribute("data-generation")!)
|
||||
setRenderedGeneration(
|
||||
parseInt(slotsRoot.getAttribute("data-generation")!)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(slotGrid, { attributes: true });
|
||||
observer.observe(slotsRoot, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [slotGrid, setSlotGridGeneration]);
|
||||
}, [slotsRoot, setRenderedGeneration]);
|
||||
|
||||
const [gridRef1, gridBounds] = useMeasure();
|
||||
const gridRef2 = useRef<HTMLDivElement | null>(null);
|
||||
const gridRef = useMergedRefs(gridRef1, gridRef2);
|
||||
|
||||
const slotRects = useMemo(() => {
|
||||
if (slotGrid === null) return [];
|
||||
if (slotsRoot === null) return [];
|
||||
|
||||
const slots = slotGrid.getElementsByClassName(styles.slot);
|
||||
const slots = slotsRoot.getElementsByClassName(styles.slot);
|
||||
const rects = new Array<Rect>(slots.length);
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
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
|
||||
// eslint can't statically verify this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slotGrid, slotGridGeneration, gridBounds]);
|
||||
}, [slotsRoot, renderedGeneration, gridBounds]);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
// The grid bounds might not be known yet
|
||||
gridBounds.width === 0
|
||||
? null
|
||||
: Math.max(2, Math.floor(gridBounds.width * 0.0045)),
|
||||
[gridBounds]
|
||||
);
|
||||
// TODO: Implement more layouts and select the right one here
|
||||
const layout = BigGrid;
|
||||
const {
|
||||
state: grid,
|
||||
orderedItems,
|
||||
generation,
|
||||
canDragTile,
|
||||
dragTile,
|
||||
toggleFocus,
|
||||
slots,
|
||||
} = useLayout(layout, items, gridBounds, layoutStates);
|
||||
|
||||
const [grid, setGrid] = useGridState(columns, items);
|
||||
|
||||
const [tiles] = useReactiveState<Tile[]>(
|
||||
const [tiles] = useReactiveState<Tile<T>[]>(
|
||||
(prevTiles) => {
|
||||
// If React hasn't yet rendered the current generation of the grid, skip
|
||||
// the update, because grid and slotRects will be out of sync
|
||||
if (slotGridGeneration !== grid?.generation) return prevTiles ?? [];
|
||||
if (renderedGeneration !== generation) return prevTiles ?? [];
|
||||
|
||||
const tileCells = grid.cells.filter((c) => c?.origin) as Cell[];
|
||||
const tileRects = new Map<TileDescriptor<unknown>, Rect>(
|
||||
zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect])
|
||||
const tileRects = new Map(
|
||||
zip(orderedItems, slotRects) as [TileDescriptor<T>, Rect][]
|
||||
);
|
||||
// In order to not break drag gestures, it's critical that we render tiles
|
||||
// in a stable order (that of 'items')
|
||||
return items.map((item) => ({ ...tileRects.get(item)!, item }));
|
||||
},
|
||||
[slotRects, grid, slotGridGeneration]
|
||||
[slotRects, grid, renderedGeneration]
|
||||
);
|
||||
|
||||
// Drag state is stored in a ref rather than component state, because we use
|
||||
|
@ -249,8 +169,8 @@ export function NewVideoGrid<T>({
|
|||
const [tileTransitions, springRef] = useTransition(
|
||||
tiles,
|
||||
() => ({
|
||||
key: ({ item }: Tile) => item.id,
|
||||
from: ({ x, y, width, height }: Tile) => ({
|
||||
key: ({ item }: Tile<T>) => item.id,
|
||||
from: ({ x, y, width, height }: Tile<T>) => ({
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
shadow: 1,
|
||||
|
@ -263,7 +183,7 @@ export function NewVideoGrid<T>({
|
|||
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
|
||||
? null
|
||||
: {
|
||||
|
@ -277,7 +197,7 @@ export function NewVideoGrid<T>({
|
|||
config: { mass: 0.7, tension: 252, friction: 25 },
|
||||
})
|
||||
// react-spring's types are bugged and can't infer the spring type
|
||||
) as unknown as [TransitionFn<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
|
||||
// firing animations manually whenever the tiles array updates
|
||||
|
@ -288,11 +208,9 @@ export function NewVideoGrid<T>({
|
|||
const animateDraggedTile = (endOfGesture: boolean) => {
|
||||
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
|
||||
const tile = tiles.find((t) => t.item.id === tileId)!;
|
||||
const originIndex = grid!.cells.findIndex((c) => c?.item.id === tileId);
|
||||
const originCell = grid!.cells[originIndex]!;
|
||||
|
||||
springRef.current
|
||||
.find((c) => (c.item as Tile).item.id === tileId)
|
||||
.find((c) => (c.item as Tile<T>).item.id === tileId)
|
||||
?.start(
|
||||
endOfGesture
|
||||
? {
|
||||
|
@ -320,36 +238,23 @@ export function NewVideoGrid<T>({
|
|||
}
|
||||
);
|
||||
|
||||
const columns = grid!.columns;
|
||||
const rows = row(grid!.cells.length - 1, grid!) + 1;
|
||||
|
||||
const cursorColumn = Math.floor(
|
||||
(cursorX / slotGrid!.clientWidth) * columns
|
||||
);
|
||||
const cursorRow = Math.floor((cursorY / slotGrid!.clientHeight) * rows);
|
||||
|
||||
const cursorColumnOnTile = Math.floor(
|
||||
((cursorX - tileX) / tile.width) * originCell.columns
|
||||
);
|
||||
const cursorRowOnTile = Math.floor(
|
||||
((cursorY - tileY) / tile.height) * originCell.rows
|
||||
const overTile = tiles.find(
|
||||
(t) =>
|
||||
cursorX >= t.x &&
|
||||
cursorX < t.x + t.width &&
|
||||
cursorY >= t.y &&
|
||||
cursorY < t.y + t.height
|
||||
);
|
||||
|
||||
const dest =
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
columns - originCell.columns,
|
||||
cursorColumn - cursorColumnOnTile
|
||||
)
|
||||
) +
|
||||
grid!.columns *
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile)
|
||||
);
|
||||
|
||||
if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest));
|
||||
if (overTile !== undefined)
|
||||
dragTile(
|
||||
tile.item,
|
||||
overTile.item,
|
||||
(cursorX - tileX) / tile.width,
|
||||
(cursorY - tileY) / tile.height,
|
||||
(cursorX - overTile.x) / overTile.width,
|
||||
(cursorY - overTile.y) / overTile.height
|
||||
);
|
||||
};
|
||||
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
|
@ -367,29 +272,33 @@ export function NewVideoGrid<T>({
|
|||
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => {
|
||||
if (tap) {
|
||||
setGrid((g) => cycleTileSize(tileId, g!));
|
||||
toggleFocus?.(items.find((i) => i.id === tileId)!);
|
||||
} else {
|
||||
const tileSpring = springRef.current
|
||||
.find((c) => (c.item as Tile).item.id === tileId)!
|
||||
.get();
|
||||
const tileController = springRef.current.find(
|
||||
(c) => (c.item as Tile<T>).item.id === tileId
|
||||
)!;
|
||||
|
||||
if (dragState.current === null) {
|
||||
dragState.current = {
|
||||
tileId,
|
||||
tileX: tileSpring.x,
|
||||
tileY: tileSpring.y,
|
||||
cursorX: initialX - gridBounds.x,
|
||||
cursorY: initialY - gridBounds.y + scrollOffset.current,
|
||||
};
|
||||
if (canDragTile((tileController.item as Tile<T>).item)) {
|
||||
if (dragState.current === null) {
|
||||
const tileSpring = tileController.get();
|
||||
dragState.current = {
|
||||
tileId,
|
||||
tileX: tileSpring.x,
|
||||
tileY: tileSpring.y,
|
||||
cursorX: initialX - gridBounds.x,
|
||||
cursorY: initialY - gridBounds.y + scrollOffset.current,
|
||||
};
|
||||
}
|
||||
|
||||
dragState.current.tileX += dx;
|
||||
dragState.current.tileY += dy;
|
||||
dragState.current.cursorX += dx;
|
||||
dragState.current.cursorY += dy;
|
||||
|
||||
animateDraggedTile(last);
|
||||
|
||||
if (last) dragState.current = null;
|
||||
}
|
||||
dragState.current.tileX += dx;
|
||||
dragState.current.tileY += dy;
|
||||
dragState.current.cursorX += dx;
|
||||
dragState.current.cursorY += dy;
|
||||
|
||||
animateDraggedTile(last);
|
||||
|
||||
if (last) dragState.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -411,52 +320,6 @@ export function NewVideoGrid<T>({
|
|||
{ 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
|
||||
if (grid === null) {
|
||||
return <div ref={gridRef} className={styles.grid} />;
|
||||
|
@ -465,10 +328,9 @@ export function NewVideoGrid<T>({
|
|||
return (
|
||||
<div ref={gridRef} className={styles.grid}>
|
||||
<div
|
||||
style={slotGridStyle}
|
||||
ref={setSlotGrid}
|
||||
className={styles.slotGrid}
|
||||
data-generation={grid.generation}
|
||||
ref={setSlotsRoot}
|
||||
className={styles.slots}
|
||||
data-generation={generation}
|
||||
>
|
||||
{slots}
|
||||
</div>
|
||||
|
@ -482,7 +344,7 @@ export function NewVideoGrid<T>({
|
|||
data={tile.item.data}
|
||||
{...spring}
|
||||
>
|
||||
{children as (props: ChildrenProperties<unknown>) => ReactNode}
|
||||
{children as (props: ChildrenProperties<T>) => ReactNode}
|
||||
</TileWrapper>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, memo, ReactNode, RefObject, useRef } from "react";
|
||||
import React, { memo, ReactNode, RefObject, useRef } from "react";
|
||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||
import { SpringValue, to } from "@react-spring/web";
|
||||
|
||||
|
@ -47,7 +47,7 @@ interface Props<T> {
|
|||
* A wrapper around a tile in a video grid. This component exists to decouple
|
||||
* child components from the grid.
|
||||
*/
|
||||
export const TileWrapper: FC<Props<unknown>> = memo(
|
||||
export const TileWrapper = memo(
|
||||
({
|
||||
id,
|
||||
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;
|
||||
|
|
|
@ -42,6 +42,7 @@ import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer"
|
|||
import styles from "./VideoGrid.module.css";
|
||||
import { Layout } from "../room/GridLayoutMenu";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
import { LayoutStatesMap } from "./Layout";
|
||||
|
||||
interface TilePosition {
|
||||
x: number;
|
||||
|
@ -817,6 +818,7 @@ export interface VideoGridProps<T> {
|
|||
items: TileDescriptor<T>[];
|
||||
layout: Layout;
|
||||
disableAnimations: boolean;
|
||||
layoutStates: LayoutStatesMap;
|
||||
children: (props: ChildrenProperties<T>) => React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
@ -34,8 +34,10 @@ import styles from "./VideoTile.module.css";
|
|||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { FullscreenButton } from "../button/Button";
|
||||
|
||||
export interface ItemData {
|
||||
id: string;
|
||||
member?: RoomMember;
|
||||
sfuParticipant: LocalParticipant | RemoteParticipant;
|
||||
content: TileContent;
|
||||
|
@ -48,7 +50,9 @@ export enum TileContent {
|
|||
|
||||
interface Props {
|
||||
data: ItemData;
|
||||
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onToggleFullscreen: (itemId: string) => void;
|
||||
// TODO: Refactor these props.
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
|
@ -62,6 +66,9 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
|
|||
(
|
||||
{
|
||||
data,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
|
@ -93,17 +100,33 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
|
|||
}
|
||||
}, [member, setDisplayName]);
|
||||
|
||||
const audioEl = React.useRef<HTMLAudioElement>(null);
|
||||
const { isMuted: microphoneMuted } = useMediaTrack(
|
||||
content === TileContent.UserMedia
|
||||
? Track.Source.Microphone
|
||||
: Track.Source.ScreenShareAudio,
|
||||
sfuParticipant,
|
||||
{
|
||||
element: audioEl,
|
||||
}
|
||||
sfuParticipant
|
||||
);
|
||||
|
||||
const onFullscreen = useCallback(() => {
|
||||
onToggleFullscreen(data.id);
|
||||
}, [data, onToggleFullscreen]);
|
||||
|
||||
const toolbarButtons: JSX.Element[] = [];
|
||||
if (!sfuParticipant.isLocal) {
|
||||
// TODO local volume option, which would also go here
|
||||
|
||||
if (content === TileContent.ScreenShare) {
|
||||
toolbarButtons.push(
|
||||
<FullscreenButton
|
||||
key="fullscreen"
|
||||
className={styles.button}
|
||||
fullscreen={fullscreen}
|
||||
onPress={onFullscreen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||
|
||||
|
@ -117,11 +140,15 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
|
|||
showSpeakingIndicator,
|
||||
[styles.muted]: microphoneMuted,
|
||||
[styles.screenshare]: content === TileContent.ScreenShare,
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={style}
|
||||
ref={tileRef}
|
||||
data-testid="videoTile"
|
||||
>
|
||||
{toolbarButtons.length > 0 && (!maximised || fullscreen) && (
|
||||
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
|
||||
)}
|
||||
{content === TileContent.UserMedia && !sfuParticipant.isCameraEnabled && (
|
||||
<>
|
||||
<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}>
|
||||
<span>{t("{{displayName}} is presenting", { displayName })}</span>
|
||||
</div>
|
||||
|
@ -155,7 +182,6 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
|
|||
: Track.Source.ScreenShare
|
||||
}
|
||||
/>
|
||||
<audio ref={audioEl} />
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -20,23 +20,23 @@ import {
|
|||
cycleTileSize,
|
||||
fillGaps,
|
||||
forEachCellInArea,
|
||||
Grid,
|
||||
BigGridState,
|
||||
resize,
|
||||
row,
|
||||
tryMoveTile,
|
||||
} from "../../src/video-grid/model";
|
||||
moveTile,
|
||||
} from "../../src/video-grid/BigGrid";
|
||||
import { TileDescriptor } from "../../src/video-grid/VideoGrid";
|
||||
|
||||
/**
|
||||
* 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 columns = secondNewline === -1 ? spec.length : secondNewline - 1;
|
||||
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
|
||||
const areas = new Set(cells);
|
||||
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) {
|
||||
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.
|
||||
*/
|
||||
function showGrid(g: Grid): string {
|
||||
function showGrid(g: BigGridState): string {
|
||||
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";
|
||||
result += c?.item.id ?? " ";
|
||||
});
|
||||
result += g.cells[i]?.item.id ?? " ";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
@ -222,21 +222,12 @@ function testCycleTileSize(
|
|||
output: string
|
||||
): void {
|
||||
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(
|
||||
"expands a tile to 2×2 in a 3 column layout",
|
||||
"c",
|
||||
|
@ -345,8 +336,8 @@ abc
|
|||
def`,
|
||||
`
|
||||
abc
|
||||
gfe
|
||||
d`
|
||||
g
|
||||
def`
|
||||
);
|
||||
|
||||
testAddItems(
|
||||
|
@ -362,19 +353,19 @@ gge
|
|||
d`
|
||||
);
|
||||
|
||||
function testTryMoveTile(
|
||||
function testMoveTile(
|
||||
title: string,
|
||||
from: number,
|
||||
to: number,
|
||||
input: string,
|
||||
output: string
|
||||
): void {
|
||||
test(`tryMoveTile ${title}`, () => {
|
||||
expect(showGrid(tryMoveTile(mkGrid(input), from, to))).toBe(output);
|
||||
test(`moveTile ${title}`, () => {
|
||||
expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testTryMoveTile(
|
||||
testMoveTile(
|
||||
"refuses to move a tile too far to the left",
|
||||
1,
|
||||
-1,
|
||||
|
@ -384,7 +375,7 @@ abc`,
|
|||
abc`
|
||||
);
|
||||
|
||||
testTryMoveTile(
|
||||
testMoveTile(
|
||||
"refuses to move a tile too far to the right",
|
||||
1,
|
||||
3,
|
||||
|
@ -394,7 +385,7 @@ abc`,
|
|||
abc`
|
||||
);
|
||||
|
||||
testTryMoveTile(
|
||||
testMoveTile(
|
||||
"moves a large tile to an unoccupied space",
|
||||
3,
|
||||
1,
|
||||
|
@ -408,7 +399,7 @@ bcc
|
|||
d e`
|
||||
);
|
||||
|
||||
testTryMoveTile(
|
||||
testMoveTile(
|
||||
"refuses to move a large tile to an occupied space",
|
||||
3,
|
||||
1,
|
Loading…
Reference in a new issue