diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 2de62a80..1b517cfc 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -245,6 +245,23 @@ export function SettingsButton({ ); } +export function SimulateJoinButton(props: { + className?: string; + // TODO: add all props for + + ); +} + export function InviteButton({ className, variant = "toolbar", diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b8284975..282bfc46 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -42,6 +42,7 @@ import { ScreenshareButton, SettingsButton, InviteButton, + SimulateJoinButton, } from "../button"; import { Header, @@ -58,6 +59,7 @@ import { import { useShowInspector, useShowConnectionStats, + useVideoGridSandboxMode, } from "../settings/useSetting"; import { useModalTriggerState } from "../Modal"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; @@ -83,6 +85,8 @@ import { useFullscreen } from "./useFullscreen"; import { useLayoutStates } from "../video-grid/Layout"; import { useSFUConfig } from "../livekit/OpenIDLoader"; import { E2EELock } from "../E2EELock"; +import { useFakeTiles } from "./useFakeTiles"; +import { FakeItemData, FakeVideoTile } from "../video-grid/FakeVideoTile"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); // There is currently a bug in Safari our our code with cloning and sending MediaStreams @@ -160,6 +164,9 @@ export function InCallView({ const [showInspector] = useShowInspector(); const [showConnectionStats] = useShowConnectionStats(); + const [videoGridSandboxMode] = useVideoGridSandboxMode() + + const { fakeTiles: fakeItems, simulateJoin } = useFakeTiles() const { hideScreensharing } = useUrlParams(); @@ -227,9 +234,11 @@ export function InCallView({ const reducedControls = boundsValid && bounds.width <= 400; const noControls = reducedControls && bounds.height <= 400; - const items = useParticipantTiles(livekitRoom, participants); + const realItems = useParticipantTiles(livekitRoom, participants); const { fullscreenItem, toggleFullscreen, exitFullscreen } = - useFullscreen(items); + useFullscreen(realItems); + + const items = useMemo[]>(() => videoGridSandboxMode ? [...realItems, ...fakeItems] : realItems, [videoGridSandboxMode, realItems, fakeItems]) // The maximised participant: either the participant that the user has // manually put in fullscreen, or the focused (active) participant if the @@ -238,9 +247,9 @@ export function InCallView({ () => fullscreenItem ?? (noControls - ? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null + ? realItems.find((item) => item.isSpeaker) ?? realItems.at(0) ?? null : null), - [fullscreenItem, noControls, items] + [fullscreenItem, noControls, realItems] ); const Grid = @@ -283,16 +292,19 @@ export function InCallView({ disableAnimations={prefersReducedMotion || isSafari} layoutStates={layoutStates} > - {(props) => ( + {(props) => props.data.type === "real" ? ( 2} + showSpeakingIndicator={realItems.length > 2} showConnectionStats={showConnectionStats} {...props} + data={props.data} ref={props.ref as Ref} /> + ) : ( + )} ); @@ -381,6 +393,8 @@ export function InCallView({ ); } buttons.push(); + if (videoGridSandboxMode) + buttons.push() } buttons.push( @@ -480,7 +494,7 @@ function useParticipantTiles( const member = matrixParticipants.get(id); allGhosts &&= member === undefined; - const userMediaTile = { + const userMediaTile: TileDescriptor = { id, focused: false, isPresenter: sfuParticipant.isScreenShareEnabled, @@ -491,6 +505,7 @@ function useParticipantTiles( local: sfuParticipant.isLocal, largeBaseSize: false, data: { + type: "real", id, member, sfuParticipant, diff --git a/src/room/useFakeTiles.ts b/src/room/useFakeTiles.ts new file mode 100644 index 00000000..259fbc8f --- /dev/null +++ b/src/room/useFakeTiles.ts @@ -0,0 +1,96 @@ +/* +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 { TileDescriptor } from '../video-grid/VideoGrid' +import { FakeItemData } from '../video-grid/FakeVideoTile' +import { useCallback, useState } from 'react' +import { useLatest } from '../useLatest' +import { generateRandomName } from '../auth/generateRandomName' + +interface FakeTiles { + fakeTiles: TileDescriptor[] + simulateJoin: () => void +} + +export const useFakeTiles = (): FakeTiles => { + const [tiles, setTiles] = useState[]>([]) + const latestSetTiles = useLatest(setTiles) + + const simulateJoin = useCallback(() => { + const name = generateRandomName() + const newTile: TileDescriptor = { + id: name, + focused: false, + isPresenter: false, + isSpeaker: false, + hasVideo: false, + local: false, + largeBaseSize: false, + data: { + type: "fake", + name, + screenshare: false, + speaking: false, + simulateScreenshare: () => latestSetTiles.current(ts => { + if (ts.some(t => t.data.name === newTile.id && t.data.screenshare)) { + // No-op since they're already screensharing + return ts + } else { + const newScreenshareTile: TileDescriptor = { + id: `${name}:screenshare`, + focused: false, + isPresenter: false, + isSpeaker: false, + hasVideo: true, + local: false, + largeBaseSize: true, + placeNear: newTile.id, + data: { + type: "fake", + name, + screenshare: true, + speaking: false, + remove: () => latestSetTiles.current(ts => ts.filter(t => t.id !== newScreenshareTile.id)) + } + } + return [...ts, newScreenshareTile] + } + }), + simulateSpeaking: () => latestSetTiles.current(ts => ts.map(t => { + if (t.id === newTile.id) { + return { + ...t, + isSpeaker: !t.isSpeaker, + data: { + ...t.data, + speaking: !t.isSpeaker, + } + } + } else { + return t + } + })), + remove: () => latestSetTiles.current(ts => ts.filter(t => t.id !== newTile.id)) + } + } + setTiles(ts => [...ts, newTile]) + }, [setTiles, latestSetTiles]) + + return { + fakeTiles: tiles, + simulateJoin, + } +} diff --git a/src/room/useFullscreen.ts b/src/room/useFullscreen.ts index 78ec1c87..27952886 100644 --- a/src/room/useFullscreen.ts +++ b/src/room/useFullscreen.ts @@ -16,11 +16,12 @@ limitations under the License. */ import { logger } from "matrix-js-sdk/src/logger"; -import { useCallback, useLayoutEffect, useRef } from "react"; +import { useCallback, useLayoutEffect } from "react"; import { TileDescriptor } from "../video-grid/VideoGrid"; import { useReactiveState } from "../useReactiveState"; import { useEventTarget } from "../useEvents"; +import { useLatest } from "../useLatest"; const isFullscreen = () => Boolean(document.fullscreenElement) || @@ -69,11 +70,8 @@ export function useFullscreen(items: TileDescriptor[]): { [items] ); - const latestItems = useRef[]>(items); - latestItems.current = items; - - const latestFullscreenItem = useRef | null>(fullscreenItem); - latestFullscreenItem.current = fullscreenItem; + const latestItems = useLatest(items) + const latestFullscreenItem = useLatest(fullscreenItem) const toggleFullscreen = useCallback( (itemId: string) => { diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 19379cb4..3423d30e 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -34,6 +34,7 @@ import { useOptInAnalytics, useDeveloperSettingsTab, useShowConnectionStats, + useVideoGridSandboxMode, } from "./useSetting"; import { FieldRow, InputField } from "../input/Input"; import { Button } from "../button"; @@ -68,6 +69,7 @@ export const SettingsModal = (props: Props) => { useDeveloperSettingsTab(); const [showConnectionStats, setShowConnectionStats] = useShowConnectionStats(); + const [videoGridSandboxMode, setVideoGridSandboxMode] = useVideoGridSandboxMode() const downloadDebugLog = useDownloadDebugLog(); @@ -249,6 +251,18 @@ export const SettingsModal = (props: Props) => { } /> + + ) => + setVideoGridSandboxMode(e.target.checked) + } + /> + diff --git a/src/settings/useSetting.ts b/src/settings/useSetting.ts index 63f1e23a..61933213 100644 --- a/src/settings/useSetting.ts +++ b/src/settings/useSetting.ts @@ -104,6 +104,8 @@ export const useDeveloperSettingsTab = () => export const useShowConnectionStats = () => useSetting("show-connection-stats", false); +export const useVideoGridSandboxMode = () => useSetting("video-grid-sandbox-mode", false) + export const useDefaultDevices = () => useSetting("defaultDevices", { audioinput: "", diff --git a/src/useLatest.ts b/src/useLatest.ts new file mode 100644 index 00000000..fa54c1e4 --- /dev/null +++ b/src/useLatest.ts @@ -0,0 +1,27 @@ +/* +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 { useRef } from "react" + +/** + * Returns a ref that always holds the latest value passed to this hook. + */ +export const useLatest = (t: T): { current: T } => { + const latest = useRef(t) + latest.current = t + return latest +} diff --git a/src/video-grid/FakeVideoTile.module.css b/src/video-grid/FakeVideoTile.module.css new file mode 100644 index 00000000..f0444f06 --- /dev/null +++ b/src/video-grid/FakeVideoTile.module.css @@ -0,0 +1,42 @@ +/* +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. +*/ + +.tile { + background-color: var(--system); + --tileRadius: 20px; + border-radius: var(--tileRadius); + box-sizing: border-box; + padding: 12px; + user-select: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; +} + +.speaking { + border: 4px solid var(--accent); +} + +.name { + font-size: var(--font-size-caption); +} + +.buttons { + display: flex; + gap: 8px; +} diff --git a/src/video-grid/FakeVideoTile.tsx b/src/video-grid/FakeVideoTile.tsx new file mode 100644 index 00000000..8d331481 --- /dev/null +++ b/src/video-grid/FakeVideoTile.tsx @@ -0,0 +1,55 @@ +/* +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 { animated } from "@react-spring/web"; +import classNames from "classnames"; +import { ComponentProps, forwardRef, Ref } from "react" +import { Avatar } from "../Avatar"; +import { ReactComponent as HangupIcon } from "../icons/Hangup.svg"; +import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg"; +import { ReactComponent as MicIcon } from "../icons/Mic.svg"; +import styles from "./FakeVideoTile.module.css"; + + +export interface FakeItemData { + type: "fake", // To differentiate from ItemData + name: string, + screenshare: boolean, + speaking: boolean, + simulateScreenshare?: () => void + simulateSpeaking?: () => void + remove: () => void +} + +interface Props { + data: FakeItemData + className?: string; + style?: ComponentProps["style"]; +} + +export const FakeVideoTile = forwardRef(({ data, className, style }, ref) => { + return } style={style}> + +
+ {data.screenshare ? `${data.name} (sharing screen)` : data.name} +
+
+ {data.simulateSpeaking && } + {data.simulateScreenshare && } + +
+
+}) diff --git a/src/video-grid/TileWrapper.module.css b/src/video-grid/TileWrapper.module.css new file mode 100644 index 00000000..17d26c25 --- /dev/null +++ b/src/video-grid/TileWrapper.module.css @@ -0,0 +1,23 @@ +/* +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. +*/ + +.tile { + position: absolute; + contain: strict; + overflow: hidden; + cursor: pointer; + top: 0; +} diff --git a/src/video-grid/TileWrapper.tsx b/src/video-grid/TileWrapper.tsx index d1712ce5..bf5ed7c3 100644 --- a/src/video-grid/TileWrapper.tsx +++ b/src/video-grid/TileWrapper.tsx @@ -19,6 +19,7 @@ import { EventTypes, Handler, useDrag } from "@use-gesture/react"; import { SpringValue, to } from "@react-spring/web"; import { ChildrenProperties } from "./VideoGrid"; +import styles from "./TileWrapper.module.css" interface Props { id: string; @@ -77,6 +78,7 @@ export const TileWrapper = memo( <> {children({ ref, + className: styles.tile, style: { opacity, scale, diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index 77393b8b..a96687cf 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -802,6 +802,7 @@ interface DragTileData { export interface ChildrenProperties { ref: Ref; + className?: string; style: ComponentProps["style"]; /** * The width this tile will have once its animations have settled. diff --git a/src/video-grid/VideoTile.module.css b/src/video-grid/VideoTile.module.css index 20849a94..f579e5c5 100644 --- a/src/video-grid/VideoTile.module.css +++ b/src/video-grid/VideoTile.module.css @@ -15,15 +15,11 @@ limitations under the License. */ .videoTile { - position: absolute; - contain: strict; - top: 0; container-name: videoTile; container-type: size; --tileRadius: 8px; border-radius: var(--tileRadius); overflow: hidden; - cursor: pointer; /* HACK: This has no visual effect due to the short duration, but allows the JS to detect movement via the transform property for audio spatialization */ @@ -154,7 +150,7 @@ limitations under the License. .videoMutedOverlay { width: 100%; height: 100%; - background-color: #21262c; + background-color: var(--system); } .presenterLabel { diff --git a/src/video-grid/VideoTile.tsx b/src/video-grid/VideoTile.tsx index 63ceddf2..30486743 100644 --- a/src/video-grid/VideoTile.tsx +++ b/src/video-grid/VideoTile.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ComponentProps, forwardRef, useCallback, useEffect } from "react"; +import { ComponentProps, forwardRef, Ref, useCallback, useEffect } from "react"; import { animated } from "@react-spring/web"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; @@ -39,6 +39,7 @@ import { useModalTriggerState } from "../Modal"; import { VideoTileSettingsModal } from "./VideoTileSettingsModal"; export interface ItemData { + type: "real", // To differentiate from FakeItemData id: string; member?: RoomMember; sfuParticipant: LocalParticipant | RemoteParticipant; @@ -64,7 +65,7 @@ interface Props { showConnectionStats: boolean; } -export const VideoTile = forwardRef( +export const VideoTile = forwardRef( ( { data, @@ -159,7 +160,7 @@ export const VideoTile = forwardRef( [styles.maximised]: maximised, })} style={style} - ref={tileRef} + ref={tileRef as Ref} data-testid="videoTile" > {toolbarButtons.length > 0 && (!maximised || fullscreen) && (