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:
|
on:
|
||||||
pull_request: {}
|
pull_request: {}
|
||||||
push:
|
push:
|
||||||
branches: [livekit]
|
branches: [livekit, full-mesh]
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
|
|
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 не е заредена",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -71,6 +71,5 @@
|
||||||
"Close": "Κλείσιμο",
|
"Close": "Κλείσιμο",
|
||||||
"Change layout": "Αλλαγή διάταξης",
|
"Change layout": "Αλλαγή διάταξης",
|
||||||
"Camera": "Κάμερα",
|
"Camera": "Κάμερα",
|
||||||
"Audio": "Ήχος",
|
"Audio": "Ήχος"
|
||||||
"{{name}} (Connecting...)": "{{name}} (Συνδέεται...)"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>",
|
||||||
|
|
|
@ -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 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>",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>",
|
||||||
|
|
|
@ -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>",
|
||||||
|
|
|
@ -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>",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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": "Показати стан з'єднання"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>",
|
||||||
|
|
|
@ -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": "顯示連線統計資料"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
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 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
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;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
Loading…
Reference in a new issue