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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -52,7 +52,6 @@
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Andere Benutzer versuchen, diesem Aufruf von einer inkompatiblen Softwareversion aus beizutreten. Diese Benutzer sollten ihre Web-Browser Seite neu laden:<1>{userLis}</1>",
"Password": "Passwort",
"Passwords must match": "Passwörter müssen übereinstimmen",
"Press and hold to talk over {{name}}": "Zum Verdrängen von {{name}} und Sprechen gedrückt halten",
"Profile": "Profil",
"Recaptcha dismissed": "Recaptcha abgelehnt",
"Recaptcha not loaded": "Recaptcha nicht geladen",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
{
"{{name}} (Waiting for video...)": "{{name}}(ビデオを待機しています…)",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>既にアカウントをお持ちですか?</0><1><0>ログイン</0>または<2>ゲストとしてアクセス</2></1>",
"<0>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>",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,114 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { useCallback, useLayoutEffect, useRef } from "react";
import { TileDescriptor } from "../video-grid/VideoGrid";
import { useReactiveState } from "../useReactiveState";
import { useEventTarget } from "../useEvents";
const isFullscreen = () =>
Boolean(document.fullscreenElement) ||
Boolean(document.webkitFullscreenElement);
function enterFullscreen() {
if (document.body.requestFullscreen) {
document.body.requestFullscreen();
} else if (document.body.webkitRequestFullscreen) {
document.body.webkitRequestFullscreen();
} else {
logger.error("No available fullscreen API!");
}
}
function exitFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else {
logger.error("No available fullscreen API!");
}
}
function useFullscreenChange(onFullscreenChange: () => void) {
useEventTarget(document.body, "fullscreenchange", onFullscreenChange);
useEventTarget(document.body, "webkitfullscreenchange", onFullscreenChange);
}
/**
* Provides callbacks for controlling the full-screen view, which can hold one
* item at a time.
*/
export function useFullscreen<T>(items: TileDescriptor<T>[]): {
fullscreenItem: TileDescriptor<T> | null;
toggleFullscreen: (itemId: string) => void;
exitFullscreen: () => void;
} {
const [fullscreenItem, setFullscreenItem] =
useReactiveState<TileDescriptor<T> | null>(
(prevItem) =>
prevItem == null
? null
: items.find((i) => i.id === prevItem.id) ?? null,
[items]
);
const latestItems = useRef<TileDescriptor<T>[]>(items);
latestItems.current = items;
const latestFullscreenItem = useRef<TileDescriptor<T> | null>(fullscreenItem);
latestFullscreenItem.current = fullscreenItem;
const toggleFullscreen = useCallback(
(itemId: string) => {
setFullscreenItem(
latestFullscreenItem.current === null
? latestItems.current.find((i) => i.id === itemId) ?? null
: null
);
},
[setFullscreenItem]
);
const exitFullscreenCallback = useCallback(
() => setFullscreenItem(null),
[setFullscreenItem]
);
useLayoutEffect(() => {
// Determine whether we need to change the fullscreen state
if (isFullscreen() !== (fullscreenItem !== null)) {
(fullscreenItem === null ? exitFullscreen : enterFullscreen)();
}
}, [fullscreenItem]);
// Detect when the user exits fullscreen through an external mechanism like
// browser chrome or the escape key
useFullscreenChange(
useCallback(() => {
if (!isFullscreen()) setFullscreenItem(null);
}, [setFullscreenItem])
);
return {
fullscreenItem,
toggleFullscreen,
exitFullscreen: exitFullscreenCallback,
};
}

View file

@ -0,0 +1,29 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.bigGrid {
display: grid;
grid-auto-rows: 163px;
gap: 8px;
}
@media (min-width: 800px) {
.bigGrid {
grid-auto-rows: 183px;
column-gap: 18px;
row-gap: 21px;
}
}

View file

@ -15,33 +15,48 @@ limitations under the License.
*/
import TinyQueue from "tinyqueue";
import { RectReadOnly } from "react-use-measure";
import { FC, memo, ReactNode } from "react";
import React from "react";
import { TileDescriptor } from "./VideoGrid";
import { Slot } from "./NewVideoGrid";
import { Layout } from "./Layout";
import { count, findLastIndex } from "../array-utils";
import styles from "./BigGrid.module.css";
/**
* A 1×1 cell in a grid which belongs to a tile.
*/
export interface Cell {
interface Cell {
/**
* The item displayed on the tile.
*/
item: TileDescriptor<unknown>;
readonly item: TileDescriptor<unknown>;
/**
* Whether this cell is the origin (top left corner) of the tile.
*/
origin: boolean;
readonly origin: boolean;
/**
* The width, in columns, of the tile.
*/
columns: number;
readonly columns: number;
/**
* The height, in rows, of the tile.
*/
rows: number;
readonly rows: number;
}
export interface Grid {
export interface BigGridState {
readonly columns: number;
/**
* The cells of the grid, in left-to-right top-to-bottom order.
* undefined = empty.
*/
readonly cells: (Cell | undefined)[];
}
interface MutableBigGridState {
columns: number;
/**
* The cells of the grid, in left-to-right top-to-bottom order.
@ -58,7 +73,7 @@ export interface Grid {
* @returns An array in which each cell holds the index of the next cell to move
* to to reach the destination, or null if it is the destination.
*/
export function getPaths(dest: number, g: Grid): (number | null)[] {
export function getPaths(dest: number, g: BigGridState): (number | null)[] {
const destRow = row(dest, g);
const destColumn = column(dest, g);
@ -106,18 +121,23 @@ export function getPaths(dest: number, g: Grid): (number | null)[] {
return edges as (number | null)[];
}
const findLast1By1Index = (g: Grid): number | null =>
const findLast1By1Index = (g: BigGridState): number | null =>
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
export function row(index: number, g: Grid): number {
export function row(index: number, g: BigGridState): number {
return Math.floor(index / g.columns);
}
export function column(index: number, g: Grid): number {
export function column(index: number, g: BigGridState): number {
return ((index % g.columns) + g.columns) % g.columns;
}
function inArea(index: number, start: number, end: number, g: Grid): boolean {
function inArea(
index: number,
start: number,
end: number,
g: BigGridState
): boolean {
const indexColumn = column(index, g);
const indexRow = row(index, g);
return (
@ -131,7 +151,7 @@ function inArea(index: number, start: number, end: number, g: Grid): boolean {
function* cellsInArea(
start: number,
end: number,
g: Grid
g: BigGridState
): Generator<number, void, unknown> {
const startColumn = column(start, g);
const endColumn = column(end, g);
@ -149,7 +169,7 @@ function* cellsInArea(
export function forEachCellInArea(
start: number,
end: number,
g: Grid,
g: BigGridState,
fn: (c: Cell | undefined, i: number) => void
): void {
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
@ -158,7 +178,7 @@ export function forEachCellInArea(
function allCellsInArea(
start: number,
end: number,
g: Grid,
g: BigGridState,
fn: (c: Cell | undefined, i: number) => boolean
): boolean {
for (const i of cellsInArea(start, end, g)) {
@ -172,16 +192,19 @@ const areaEnd = (
start: number,
columns: number,
rows: number,
g: Grid
g: BigGridState
): number => start + columns - 1 + g.columns * (rows - 1);
const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] });
const cloneGrid = (g: BigGridState): BigGridState => ({
...g,
cells: [...g.cells],
});
/**
* Gets the index of the next gap in the grid that should be backfilled by 1×1
* tiles.
*/
function getNextGap(g: Grid): number | null {
function getNextGap(g: BigGridState): number | null {
const last1By1Index = findLast1By1Index(g);
if (last1By1Index === null) return null;
@ -204,7 +227,7 @@ function getNextGap(g: Grid): number | null {
/**
* Gets the index of the origin of the tile to which the given cell belongs.
*/
function getOrigin(g: Grid, index: number): number {
function getOrigin(g: BigGridState, index: number): number {
const initialColumn = column(index, g);
for (
@ -229,7 +252,7 @@ function getOrigin(g: Grid, index: number): number {
* along the way.
* Precondition: the destination area must consist of only 1×1 tiles.
*/
function moveTile(g: Grid, from: number, to: number) {
function moveTileUnchecked(g: BigGridState, from: number, to: number) {
const tile = g.cells[from]!;
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
@ -262,10 +285,15 @@ function moveTile(g: Grid, from: number, to: number) {
/**
* Moves the tile at index "from" over to index "to", if there is space.
*/
export function tryMoveTile(g: Grid, from: number, to: number): Grid {
export function moveTile(
g: BigGridState,
from: number,
to: number
): BigGridState {
const tile = g.cells[from]!;
if (
to !== from && // Skip the operation if nothing would move
to >= 0 &&
to < g.cells.length &&
column(to, g) <= g.columns - tile.columns
@ -283,7 +311,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
if (allCellsInArea(to, toEnd, g, displaceable)) {
// The target space is free; move
const gClone = cloneGrid(g);
moveTile(gClone, from, to);
moveTileUnchecked(gClone, from, to);
return gClone;
}
}
@ -297,7 +325,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
* enlarged tiles around when necessary.
* @returns Whether the tile was actually pushed
*/
function pushTileUp(g: Grid, from: number): boolean {
function pushTileUp(g: BigGridState, from: number): boolean {
const tile = g.cells[from]!;
// TODO: pushing large tiles sideways might be more successful in some
@ -315,7 +343,7 @@ function pushTileUp(g: Grid, from: number): boolean {
);
if (cellsAboveAreDisplacable) {
moveTile(g, from, from - g.columns);
moveTileUnchecked(g, from, from - g.columns);
return true;
} else {
return false;
@ -325,8 +353,8 @@ function pushTileUp(g: Grid, from: number): boolean {
/**
* Backfill any gaps in the grid.
*/
export function fillGaps(g: Grid): Grid {
const result = cloneGrid(g);
export function fillGaps(g: BigGridState): BigGridState {
const result = cloneGrid(g) as MutableBigGridState;
// This will hopefully be the size of the grid after we're done here, assuming
// that we can pack the large tiles tightly enough
@ -403,7 +431,11 @@ export function fillGaps(g: Grid): Grid {
return result;
}
function createRows(g: Grid, count: number, atRow: number): Grid {
function createRows(
g: BigGridState,
count: number,
atRow: number
): BigGridState {
const result = {
columns: g.columns,
cells: new Array(g.cells.length + g.columns * count),
@ -430,9 +462,12 @@ function createRows(g: Grid, count: number, atRow: number): Grid {
}
/**
* Adds a set of new items into the grid.
* Adds a set of new items into the grid. (May leave gaps.)
*/
export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
export function addItems(
items: TileDescriptor<unknown>[],
g: BigGridState
): BigGridState {
let result = cloneGrid(g);
for (const item of items) {
@ -444,13 +479,11 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
};
let placeAt: number;
let hasGaps: boolean;
if (item.placeNear === undefined) {
// This item has no special placement requests, so let's put it
// uneventfully at the end of the grid
placeAt = result.cells.length;
hasGaps = false;
} else {
// This item wants to be placed near another; let's put it on a row
// directly below the related tile
@ -460,7 +493,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
if (placeNear === -1) {
// Can't find the related tile, so let's give up and place it at the end
placeAt = result.cells.length;
hasGaps = false;
} else {
const placeNearCell = result.cells[placeNear]!;
const placeNearEnd = areaEnd(
@ -475,7 +507,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
placeNear +
Math.floor(placeNearCell.columns / 2) +
result.columns * placeNearCell.rows;
hasGaps = true;
}
}
@ -484,21 +515,19 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
if (item.largeBaseSize) {
// Cycle the tile size once to set up the tile with its larger base size
// This also fills any gaps in the grid, hence no extra call to fillGaps
result = cycleTileSize(item.id, result);
} else if (hasGaps) {
result = fillGaps(result);
result = cycleTileSize(result, item);
}
}
return result;
}
const largeTileDimensions = (g: Grid): [number, number] => [
const largeTileDimensions = (g: BigGridState): [number, number] => [
Math.min(3, Math.max(2, g.columns - 1)),
2,
];
const extraLargeTileDimensions = (g: Grid): [number, number] =>
const extraLargeTileDimensions = (g: BigGridState): [number, number] =>
g.columns > 3 ? [4, 3] : [g.columns, 2];
/**
@ -507,8 +536,11 @@ const extraLargeTileDimensions = (g: Grid): [number, number] =>
* @param g The grid.
* @returns The updated grid.
*/
export function cycleTileSize(tileId: string, g: Grid): Grid {
const from = g.cells.findIndex((c) => c?.item.id === tileId);
export function cycleTileSize(
g: BigGridState,
tile: TileDescriptor<unknown>
): BigGridState {
const from = g.cells.findIndex((c) => c?.item === tile);
if (from === -1) return g; // Tile removed, no change
const fromCell = g.cells[from]!;
const fromWidth = fromCell.columns;
@ -629,8 +661,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
/**
* Resizes the grid to a new column width.
*/
export function resize(g: Grid, columns: number): Grid {
const result: Grid = { columns, cells: [] };
export function resize(g: BigGridState, columns: number): BigGridState {
const result: BigGridState = { columns, cells: [] };
const [largeColumns, largeRows] = largeTileDimensions(result);
// Copy each tile from the old grid to the resized one in the same order
@ -640,6 +672,7 @@ export function resize(g: Grid, columns: number): Grid {
for (const cell of g.cells) {
if (cell?.origin) {
// TODO make aware of extra large tiles
const [nextColumns, nextRows] =
cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1];
@ -672,7 +705,7 @@ export function resize(g: Grid, columns: number): Grid {
/**
* Promotes speakers to the first page of the grid.
*/
export function promoteSpeakers(g: Grid) {
export function promoteSpeakers(g: BigGridState) {
// This is all a bit of a hack right now, because we don't know if the designs
// will stick with this approach in the long run
// We assume that 4 rows are probably about 1 page
@ -694,10 +727,149 @@ export function promoteSpeakers(g: Grid) {
toCell === undefined ||
(toCell.columns === 1 && toCell.rows === 1)
) {
moveTile(g, from, to);
moveTileUnchecked(g, from, to);
break;
}
}
}
}
}
/**
* The algorithm for updating a grid with a new set of tiles.
*/
function updateTiles(
g: BigGridState,
tiles: TileDescriptor<unknown>[]
): BigGridState {
// Step 1: Update tiles that still exist, and remove tiles that have left
// the grid
const itemsById = new Map(tiles.map((i) => [i.id, i]));
const grid1: BigGridState = {
...g,
cells: g.cells.map((c) => {
if (c === undefined) return undefined;
const item = itemsById.get(c.item.id);
return item === undefined ? undefined : { ...c, item };
}),
};
// Step 2: Add new tiles
const existingItemIds = new Set(
grid1.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
);
const newItems = tiles.filter((i) => !existingItemIds.has(i.id));
const grid2 = addItems(newItems, grid1);
// Step 3: Promote speakers to the top
promoteSpeakers(grid2);
return fillGaps(grid2);
}
function updateBounds(g: BigGridState, bounds: RectReadOnly): BigGridState {
const columns = Math.max(2, Math.floor(bounds.width * 0.0045));
return columns === g.columns ? g : resize(g, columns);
}
const Slots: FC<{ s: BigGridState }> = memo(({ s: g }) => {
const areas = new Array<(number | null)[]>(
Math.ceil(g.cells.length / g.columns)
);
for (let i = 0; i < areas.length; i++)
areas[i] = new Array<number | null>(g.columns).fill(null);
let slotCount = 0;
for (let i = 0; i < g.cells.length; i++) {
const cell = g.cells[i];
if (cell?.origin) {
const slotEnd = i + cell.columns - 1 + g.columns * (cell.rows - 1);
forEachCellInArea(
i,
slotEnd,
g,
(_c, j) => (areas[row(j, g)][column(j, g)] = slotCount)
);
slotCount++;
}
}
const style = {
gridTemplateAreas: areas
.map(
(row) =>
`'${row
.map((slotId) => (slotId === null ? "." : `s${slotId}`))
.join(" ")}'`
)
.join(" "),
gridTemplateColumns: `repeat(${g.columns}, 1fr)`,
};
const slots = new Array<ReactNode>(slotCount);
for (let i = 0; i < slotCount; i++)
slots[i] = <Slot key={i} style={{ gridArea: `s${i}` }} />;
return (
<div className={styles.bigGrid} style={style}>
{slots}
</div>
);
});
/**
* Given a tile and numbers in the range [0, 1) describing a position within the
* tile, this returns the index of the specific cell in which that position
* lies.
*/
function positionOnTileToCell(
g: BigGridState,
tileOriginIndex: number,
xPositionOnTile: number,
yPositionOnTile: number
): number {
const tileOrigin = g.cells[tileOriginIndex]!;
const columnOnTile = Math.floor(xPositionOnTile * tileOrigin.columns);
const rowOnTile = Math.floor(yPositionOnTile * tileOrigin.rows);
return tileOriginIndex + columnOnTile + g.columns * rowOnTile;
}
function dragTile(
g: BigGridState,
from: TileDescriptor<unknown>,
to: TileDescriptor<unknown>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number
): BigGridState {
const fromOrigin = g.cells.findIndex((c) => c?.item === from);
const toOrigin = g.cells.findIndex((c) => c?.item === to);
const fromCell = positionOnTileToCell(
g,
fromOrigin,
xPositionOnFrom,
yPositionOnFrom
);
const toCell = positionOnTileToCell(
g,
toOrigin,
xPositionOnTo,
yPositionOnTo
);
return moveTile(g, fromOrigin, fromOrigin + toCell - fromCell);
}
export const BigGrid: Layout<BigGridState> = {
emptyState: { columns: 4, cells: [] },
updateTiles,
updateBounds,
getTiles: <T,>(g) =>
g.cells.filter((c) => c?.origin).map((c) => c!.item as T),
canDragTile: () => true,
dragTile,
toggleFocus: cycleTileSize,
Slots,
rememberState: false,
};

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

@ -0,0 +1,178 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ComponentType, useCallback, useMemo, useRef } from "react";
import type { RectReadOnly } from "react-use-measure";
import { useReactiveState } from "../useReactiveState";
import type { TileDescriptor } from "./VideoGrid";
/**
* A video grid layout system with concrete states of type State.
*/
// Ideally State would be parameterized by the tile data type, but then that
// makes Layout a higher-kinded type, which isn't achievable in TypeScript
// (unless you invoke some dark type-level computation magic… 😏)
// So we're stuck with these types being a little too strong.
export interface Layout<State> {
/**
* The layout state for zero tiles.
*/
readonly emptyState: State;
/**
* Updates/adds/removes tiles in a way that looks natural in the context of
* the given initial state.
*/
readonly updateTiles: <T>(s: State, tiles: TileDescriptor<T>[]) => State;
/**
* Adapts the layout to a new container size.
*/
readonly updateBounds: (s: State, bounds: RectReadOnly) => State;
/**
* Gets tiles in the order created by the layout.
*/
readonly getTiles: <T>(s: State) => TileDescriptor<T>[];
/**
* Determines whether a tile is draggable.
*/
readonly canDragTile: <T>(s: State, tile: TileDescriptor<T>) => boolean;
/**
* Drags the tile 'from' to the location of the tile 'to' (if possible).
* The position parameters are numbers in the range [0, 1) describing the
* specific positions on 'from' and 'to' that the drag gesture is targeting.
*/
readonly dragTile: <T>(
s: State,
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number
) => State;
/**
* Toggles the focus of the given tile (if this layout has the concept of
* focus).
*/
readonly toggleFocus?: <T>(s: State, tile: TileDescriptor<T>) => State;
/**
* A React component generating the slot elements for a given layout state.
*/
readonly Slots: ComponentType<{ s: State }>;
/**
* Whether the state of this layout should be remembered even while a
* different layout is active.
*/
readonly rememberState: boolean;
}
/**
* A version of Map with stronger types that allow us to save layout states in a
* type-safe way.
*/
export interface LayoutStatesMap {
get<State>(layout: Layout<State>): State | undefined;
set<State>(layout: Layout<State>, state: State): LayoutStatesMap;
delete<State>(layout: Layout<State>): boolean;
}
/**
* Hook creating a Map to store layout states in.
*/
export const useLayoutStates = (): LayoutStatesMap => {
const layoutStates = useRef<Map<unknown, unknown>>();
if (layoutStates.current === undefined) layoutStates.current = new Map();
return layoutStates.current as LayoutStatesMap;
};
/**
* Hook which uses the provided layout system to arrange a set of items into a
* concrete layout state, and provides callbacks for user interaction.
*/
export const useLayout = <State, T>(
layout: Layout<State>,
items: TileDescriptor<T>[],
bounds: RectReadOnly,
layoutStates: LayoutStatesMap
) => {
const prevLayout = useRef<Layout<unknown>>();
const prevState = layoutStates.get(layout);
const [state, setState] = useReactiveState<State>(() => {
// If the bounds aren't known yet, don't add anything to the layout
if (bounds.width === 0) {
return layout.emptyState;
} else {
if (
prevLayout.current !== undefined &&
layout !== prevLayout.current &&
!prevLayout.current.rememberState
)
layoutStates.delete(prevLayout.current);
const baseState = layoutStates.get(layout) ?? layout.emptyState;
return layout.updateTiles(layout.updateBounds(baseState, bounds), items);
}
}, [layout, items, bounds]);
const generation = useRef<number>(0);
if (state !== prevState) generation.current++;
prevLayout.current = layout as Layout<unknown>;
// No point in remembering an empty state, plus it would end up clobbering the
// real saved state while restoring a layout
if (state !== layout.emptyState) layoutStates.set(layout, state);
return {
state,
orderedItems: useMemo(() => layout.getTiles<T>(state), [layout, state]),
generation: generation.current,
canDragTile: useCallback(
(tile: TileDescriptor<T>) => layout.canDragTile(state, tile),
[layout, state]
),
dragTile: useCallback(
(
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number
) =>
setState((s) =>
layout.dragTile(
s,
from,
to,
xPositionOnFrom,
yPositionOnFrom,
xPositionOnTo,
yPositionOnTo
)
),
[layout, setState]
),
toggleFocus: useMemo(
() =>
layout.toggleFocus &&
((tile: TileDescriptor<T>) =>
setState((s) => layout.toggleFocus!(s, tile))),
[layout, setState]
),
slots: <layout.Slots s={state} />,
};
};

View file

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

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, memo, ReactNode, RefObject, useRef } from "react";
import React, { memo, ReactNode, RefObject, useRef } from "react";
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { SpringValue, to } from "@react-spring/web";
@ -47,7 +47,7 @@ interface Props<T> {
* A wrapper around a tile in a video grid. This component exists to decouple
* child components from the grid.
*/
export const TileWrapper: FC<Props<unknown>> = memo(
export const TileWrapper = memo(
({
id,
onDragRef,
@ -97,4 +97,7 @@ export const TileWrapper: FC<Props<unknown>> = memo(
</>
);
}
);
// We pretend this component is a simple function rather than a
// NamedExoticComponent, because that's the only way we can fit in a type
// parameter
) as <T>(props: Props<T>) => JSX.Element;

View file

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

View file

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

View file

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