-
+
{state === GroupCallState.LocalCallFeedUninitialized && (
{t("Camera/microphone permissions needed to join the call.")}
diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts
index 8347fb1..5717553 100644
--- a/src/room/useLoadGroupCall.ts
+++ b/src/room/useLoadGroupCall.ts
@@ -32,7 +32,7 @@ import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
import { translatedError } from "../TranslatedError";
import { widget } from "../widget";
-const STATS_COLLECT_INTERVAL_TIME_MS = 30000;
+const STATS_COLLECT_INTERVAL_TIME_MS = 10000;
export interface GroupCallLoadState {
loading: boolean;
diff --git a/src/settings/ProfileSettingsTab.tsx b/src/settings/ProfileSettingsTab.tsx
index 07fc8e4..a645133 100644
--- a/src/settings/ProfileSettingsTab.tsx
+++ b/src/settings/ProfileSettingsTab.tsx
@@ -106,6 +106,7 @@ export function ProfileSettingsTab({ client }: Props) {
placeholder={t("Display name")}
value={displayName}
onChange={onChangeDisplayName}
+ data-testid="profile_displayname"
/>
{error && (
diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx
index 9fec9de..b3d25a5 100644
--- a/src/settings/SettingsModal.tsx
+++ b/src/settings/SettingsModal.tsx
@@ -65,7 +65,9 @@ export const SettingsModal = (props: Props) => {
audioOutput,
audioOutputs,
setAudioOutput,
+ useDeviceNames,
} = useMediaHandler();
+ useDeviceNames();
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();
diff --git a/src/settings/submit-rageshake.ts b/src/settings/submit-rageshake.ts
index 8080714..050f782 100644
--- a/src/settings/submit-rageshake.ts
+++ b/src/settings/submit-rageshake.ts
@@ -249,7 +249,7 @@ export function useSubmitRageshake(): {
body.append(
"file",
gzip(ElementCallOpenTelemetry.instance.rageshakeProcessor!.dump()),
- "traces.json"
+ "traces.json.gz"
);
if (inspectorState) {
diff --git a/src/settings/useMediaHandler.tsx b/src/settings/useMediaHandler.tsx
index 6f491c8..bf62eeb 100644
--- a/src/settings/useMediaHandler.tsx
+++ b/src/settings/useMediaHandler.tsx
@@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-/* eslint-disable @typescript-eslint/ban-ts-comment */
-
-import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
+import { MatrixClient } from "matrix-js-sdk/src/client";
import React, {
useState,
useEffect,
@@ -25,20 +23,27 @@ import React, {
useContext,
createContext,
ReactNode,
+ useRef,
} from "react";
-
import { useClient } from "../ClientContext";
+import { getNamedDevices } from "../media-utils";
+
export interface MediaHandlerContextInterface {
- audioInput: string;
+ audioInput: string | undefined;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
- videoInput: string;
+ videoInput: string | undefined;
videoInputs: MediaDeviceInfo[];
setVideoInput: (deviceId: string) => void;
- audioOutput: string;
+ audioOutput: string | undefined;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
+ /**
+ * A hook which requests for devices to be named. This requires media
+ * permissions.
+ */
+ useDeviceNames: () => void;
}
const MediaHandlerContext =
@@ -49,6 +54,7 @@ interface MediaPreferences {
videoInput?: string;
audioOutput?: string;
}
+
function getMediaPreferences(): MediaPreferences {
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
@@ -56,10 +62,10 @@ function getMediaPreferences(): MediaPreferences {
try {
return JSON.parse(mediaPreferences);
} catch (e) {
- return undefined;
+ return {};
}
} else {
- return undefined;
+ return {};
}
}
@@ -74,9 +80,11 @@ function updateMediaPreferences(newPreferences: MediaPreferences): void {
})
);
}
+
interface Props {
children: ReactNode;
}
+
export function MediaHandlerProvider({ children }: Props): JSX.Element {
const { client } = useClient();
const [
@@ -89,122 +97,109 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
audioOutputs,
},
setState,
- ] = useState(() => {
- const mediaHandler = client?.getMediaHandler();
+ ] = useState(() => ({
+ audioInput: undefined as string | undefined,
+ videoInput: undefined as string | undefined,
+ audioOutput: undefined as string | undefined,
+ audioInputs: [] as MediaDeviceInfo[],
+ videoInputs: [] as MediaDeviceInfo[],
+ audioOutputs: [] as MediaDeviceInfo[],
+ }));
- if (mediaHandler) {
+ // A ref counting the number of components currently mounted that want
+ // to know device names
+ const numComponentsWantingNames = useRef(0);
+
+ const updateDevices = useCallback(
+ async (client: MatrixClient, initial: boolean) => {
+ // Only request device names if components actually want them, because it
+ // could trigger an extra permission pop-up
+ const devices = await (numComponentsWantingNames.current > 0
+ ? getNamedDevices()
+ : navigator.mediaDevices.enumerateDevices());
const mediaPreferences = getMediaPreferences();
- mediaHandler?.restoreMediaSettings(
- mediaPreferences?.audioInput,
- mediaPreferences?.videoInput
- );
- }
- return {
- // @ts-ignore, ignore that audioInput is a private members of mediaHandler
- audioInput: mediaHandler?.audioInput,
- // @ts-ignore, ignore that videoInput is a private members of mediaHandler
- videoInput: mediaHandler?.videoInput,
- audioOutput: undefined,
- audioInputs: [],
- videoInputs: [],
- audioOutputs: [],
- };
- });
+ const audioInputs = devices.filter((d) => d.kind === "audioinput");
+ const videoInputs = devices.filter((d) => d.kind === "videoinput");
+ const audioOutputs = devices.filter((d) => d.kind === "audiooutput");
+
+ const audioInput = (
+ mediaPreferences.audioInput === undefined
+ ? audioInputs.at(0)
+ : audioInputs.find(
+ (d) => d.deviceId === mediaPreferences.audioInput
+ ) ?? audioInputs.at(0)
+ )?.deviceId;
+ const videoInput = (
+ mediaPreferences.videoInput === undefined
+ ? videoInputs.at(0)
+ : videoInputs.find(
+ (d) => d.deviceId === mediaPreferences.videoInput
+ ) ?? videoInputs.at(0)
+ )?.deviceId;
+ const audioOutput =
+ mediaPreferences.audioOutput === undefined
+ ? undefined
+ : audioOutputs.find(
+ (d) => d.deviceId === mediaPreferences.audioOutput
+ )?.deviceId;
+
+ updateMediaPreferences({ audioInput, videoInput, audioOutput });
+ setState({
+ audioInput,
+ videoInput,
+ audioOutput,
+ audioInputs,
+ videoInputs,
+ audioOutputs,
+ });
+
+ if (
+ initial ||
+ audioInput !== mediaPreferences.audioInput ||
+ videoInput !== mediaPreferences.videoInput
+ ) {
+ client.getMediaHandler().setMediaInputs(audioInput, videoInput);
+ }
+ },
+ [setState]
+ );
+
+ const useDeviceNames = useCallback(() => {
+ // This is a little weird from React's perspective as it looks like a
+ // dynamic hook, but it works
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useEffect(() => {
+ if (client) {
+ numComponentsWantingNames.current++;
+ if (numComponentsWantingNames.current === 1)
+ updateDevices(client, false);
+ return () => void numComponentsWantingNames.current--;
+ }
+ }, []);
+ }, [client, updateDevices]);
useEffect(() => {
- if (!client) return;
+ if (client) {
+ updateDevices(client, true);
+ const onDeviceChange = () => updateDevices(client, false);
+ navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
- const mediaHandler = client.getMediaHandler();
-
- function updateDevices(): void {
- navigator.mediaDevices.enumerateDevices().then((devices) => {
- const mediaPreferences = getMediaPreferences();
-
- const audioInputs = devices.filter(
- (device) => device.kind === "audioinput"
+ return () => {
+ navigator.mediaDevices.removeEventListener(
+ "devicechange",
+ onDeviceChange
);
- const audioConnected = audioInputs.some(
- // @ts-ignore
- (device) => device.deviceId === mediaHandler.audioInput
- );
- // @ts-ignore
- let audioInput = mediaHandler.audioInput;
-
- if (!audioConnected && audioInputs.length > 0) {
- audioInput = audioInputs[0].deviceId;
- }
-
- const videoInputs = devices.filter(
- (device) => device.kind === "videoinput"
- );
- const videoConnected = videoInputs.some(
- // @ts-ignore
- (device) => device.deviceId === mediaHandler.videoInput
- );
-
- // @ts-ignore
- let videoInput = mediaHandler.videoInput;
-
- if (!videoConnected && videoInputs.length > 0) {
- videoInput = videoInputs[0].deviceId;
- }
-
- const audioOutputs = devices.filter(
- (device) => device.kind === "audiooutput"
- );
- let audioOutput = undefined;
-
- if (
- mediaPreferences &&
- audioOutputs.some(
- (device) => device.deviceId === mediaPreferences.audioOutput
- )
- ) {
- audioOutput = mediaPreferences.audioOutput;
- }
-
- if (
- // @ts-ignore
- (mediaHandler.videoInput && mediaHandler.videoInput !== videoInput) ||
- // @ts-ignore
- mediaHandler.audioInput !== audioInput
- ) {
- mediaHandler.setMediaInputs(audioInput, videoInput);
- }
-
- updateMediaPreferences({ audioInput, videoInput, audioOutput });
-
- setState({
- audioInput,
- videoInput,
- audioOutput,
- audioInputs,
- videoInputs,
- audioOutputs,
- });
- });
+ client.getMediaHandler().stopAllStreams();
+ };
}
- updateDevices();
-
- mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
- navigator.mediaDevices.addEventListener("devicechange", updateDevices);
-
- return () => {
- mediaHandler.removeListener(
- MediaHandlerEvent.LocalStreamsChanged,
- updateDevices
- );
- navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
- mediaHandler.stopAllStreams();
- };
- }, [client]);
+ }, [client, updateDevices]);
const setAudioInput: (deviceId: string) => void = useCallback(
(deviceId: string) => {
updateMediaPreferences({ audioInput: deviceId });
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
- client.getMediaHandler().setAudioInput(deviceId);
+ client?.getMediaHandler().setAudioInput(deviceId);
},
[client]
);
@@ -235,6 +230,7 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
audioOutput,
audioOutputs,
setAudioOutput,
+ useDeviceNames,
}),
[
audioInput,
@@ -246,6 +242,7 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
audioOutput,
audioOutputs,
setAudioOutput,
+ useDeviceNames,
]
);
diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx
index a32c6b4..d8c45bf 100644
--- a/src/video-grid/NewVideoGrid.tsx
+++ b/src/video-grid/NewVideoGrid.tsx
@@ -245,6 +245,7 @@ export const NewVideoGrid: FC
= ({
opacity: 0,
scale: 0,
shadow: 1,
+ shadowSpread: 0,
zIndex: 1,
x,
y,
diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx
index 522924f..5fd6163 100644
--- a/src/video-grid/VideoGrid.tsx
+++ b/src/video-grid/VideoGrid.tsx
@@ -51,6 +51,7 @@ export interface TileSpring {
opacity: number;
scale: number;
shadow: number;
+ shadowSpread: number;
zIndex: number;
x: number;
y: number;
@@ -172,8 +173,16 @@ function getOneOnOneLayoutTilePositions(
const gridAspectRatio = gridWidth / gridHeight;
const smallPip = gridAspectRatio < 1 || gridWidth < 700;
- const pipWidth = smallPip ? 114 : 230;
- const pipHeight = smallPip ? 163 : 155;
+ const maxPipWidth = smallPip ? 114 : 230;
+ const maxPipHeight = smallPip ? 163 : 155;
+ // Cap the PiP size at 1/3 the remote tile size, preserving aspect ratio
+ const pipScaleFactor = Math.min(
+ 1,
+ remotePosition.width / 3 / maxPipWidth,
+ remotePosition.height / 3 / maxPipHeight
+ );
+ const pipWidth = maxPipWidth * pipScaleFactor;
+ const pipHeight = maxPipHeight * pipScaleFactor;
const pipGap = getPipGap(gridAspectRatio, gridWidth);
const pipMinX = remotePosition.x + pipGap;
@@ -892,6 +901,8 @@ export function VideoGrid({
// Whether the tile positions were valid at the time of the previous
// animation
const tilePositionsWereValid = tilePositionsValid.current;
+ const oneOnOneLayout =
+ tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused);
return (tileIndex: number) => {
const tile = tiles[tileIndex];
@@ -911,12 +922,14 @@ export function VideoGrid({
opacity: 1,
zIndex: 2,
shadow: 15,
+ shadowSpread: 0,
immediate: (key: string) =>
disableAnimations ||
key === "zIndex" ||
key === "x" ||
key === "y" ||
- key === "shadow",
+ key === "shadow" ||
+ key === "shadowSpread",
from: {
shadow: 0,
scale: 0,
@@ -974,10 +987,14 @@ export function VideoGrid({
opacity: remove ? 0 : 1,
zIndex: tilePosition.zIndex,
shadow: 1,
+ shadowSpread: oneOnOneLayout && tile.item.isLocal ? 1 : 0,
from,
reset,
immediate: (key: string) =>
- disableAnimations || key === "zIndex" || key === "shadow",
+ disableAnimations ||
+ key === "zIndex" ||
+ key === "shadow" ||
+ key === "shadowSpread",
// If we just stopped dragging a tile, give it time for the
// animation to settle before pushing its z-index back down
delay: (key: string) => (key === "zIndex" ? 500 : 0),
diff --git a/src/video-grid/VideoTile.module.css b/src/video-grid/VideoTile.module.css
index a83d1f0..8c2f7af 100644
--- a/src/video-grid/VideoTile.module.css
+++ b/src/video-grid/VideoTile.module.css
@@ -22,6 +22,8 @@ limitations under the License.
height: var(--tileHeight);
--tileRadius: 8px;
border-radius: var(--tileRadius);
+ box-shadow: rgba(0, 0, 0, 0.5) 0px var(--tileShadow)
+ calc(2 * var(--tileShadow)) var(--tileShadowSpread);
overflow: hidden;
cursor: pointer;
@@ -45,7 +47,7 @@ limitations under the License.
transform: scaleX(-1);
}
-.videoTile.speaking::after {
+.videoTile::after {
position: absolute;
top: -1px;
left: -1px;
@@ -54,6 +56,12 @@ limitations under the License.
content: "";
border-radius: var(--tileRadius);
box-shadow: inset 0 0 0 4px var(--accent) !important;
+ opacity: 0;
+ transition: opacity ease 0.15s;
+}
+
+.videoTile.speaking::after {
+ opacity: 1;
}
.videoTile.maximised {
@@ -83,6 +91,12 @@ limitations under the License.
z-index: 1;
}
+.infoBubble > svg {
+ height: 16px;
+ width: 16px;
+ margin-right: 4px;
+}
+
.toolbar {
position: absolute;
top: 0;
@@ -126,10 +140,6 @@ limitations under the License.
bottom: 16px;
}
-.memberName > * {
- margin-right: 6px;
-}
-
.memberName > :last-child {
margin-right: 0px;
}
diff --git a/src/video-grid/VideoTile.tsx b/src/video-grid/VideoTile.tsx
index eea617f..b9a2453 100644
--- a/src/video-grid/VideoTile.tsx
+++ b/src/video-grid/VideoTile.tsx
@@ -20,8 +20,8 @@ import classNames from "classnames";
import { useTranslation } from "react-i18next";
import styles from "./VideoTile.module.css";
+import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
-import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
import { AudioButton, FullscreenButton } from "../button/Button";
import { ConnectionState } from "../room/useGroupCall";
@@ -47,6 +47,7 @@ interface Props {
opacity?: SpringValue;
scale?: SpringValue;
shadow?: SpringValue;
+ shadowSpread?: SpringValue;
zIndex?: SpringValue;
x?: SpringValue;
y?: SpringValue;
@@ -79,6 +80,7 @@ export const VideoTile = forwardRef(
opacity,
scale,
shadow,
+ shadowSpread,
zIndex,
x,
y,
@@ -141,9 +143,6 @@ export const VideoTile = forwardRef(
style={{
opacity,
scale,
- boxShadow: shadow?.to(
- (s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
- ),
zIndex,
x,
y,
@@ -152,8 +151,11 @@ export const VideoTile = forwardRef(
// but React's types say no
"--tileWidth": width?.to((w) => `${w}px`),
"--tileHeight": height?.to((h) => `${h}px`),
+ "--tileShadow": shadow?.to((s) => `${s}px`),
+ "--tileShadowSpread": shadowSpread?.to((s) => `${s}px`),
}}
ref={ref as ForwardedRef}
+ data-testid="videoTile"
{...rest}
>
{toolbarButtons.length > 0 && !maximised && (
@@ -177,13 +179,19 @@ export const VideoTile = forwardRef(
Mute state is currently sent over to-device messages, which
aren't quite real-time, so this is an important kludge to make
sure no one appears muted when they've clearly begun talking. */
- audioMuted && !videoMuted && !speaking &&
+ speaking || !audioMuted ? :
}
- {videoMuted && }
- {caption}
+
+ {caption}
+
))}
-