Merge pull request #310 from vector-im/ptt
Add feature-flagged support for Radio/PTT Mode
This commit is contained in:
commit
dbef06269b
29 changed files with 1047 additions and 165 deletions
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -2,4 +2,4 @@
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.insertSpaces": true,
|
"editor.insertSpaces": true,
|
||||||
"editor.tabSize": 2
|
"editor.tabSize": 2
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
"build-storybook": "build-storybook",
|
"build-storybook": "build-storybook",
|
||||||
"prettier:check": "prettier -c src",
|
"prettier:check": "prettier -c src",
|
||||||
"prettier:format": "prettier -w src",
|
"prettier:format": "prettier -w src",
|
||||||
"lint": "eslint --max-warnings 8 src"
|
"lint": "eslint --max-warnings 11 src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@juggle/resize-observer": "^3.3.1",
|
"@juggle/resize-observer": "^3.3.1",
|
||||||
|
|
|
@ -4,35 +4,63 @@ import classNames from "classnames";
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
import { getAvatarUrl } from "./matrix-utils";
|
import { getAvatarUrl } from "./matrix-utils";
|
||||||
|
|
||||||
export function Facepile({ className, client, participants, ...rest }) {
|
const overlapMap = {
|
||||||
|
xs: 2,
|
||||||
|
sm: 4,
|
||||||
|
md: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
xs: 24,
|
||||||
|
sm: 32,
|
||||||
|
md: 36,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Facepile({
|
||||||
|
className,
|
||||||
|
client,
|
||||||
|
participants,
|
||||||
|
max,
|
||||||
|
size,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const _size = sizeMap[size];
|
||||||
|
const _overlap = overlapMap[size];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.facepile, className)}
|
className={classNames(styles.facepile, styles[size], className)}
|
||||||
title={participants.map((member) => member.name).join(", ")}
|
title={participants.map((member) => member.name).join(", ")}
|
||||||
|
style={{ width: participants.length * (_size - _overlap) + _overlap }}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{participants.slice(0, 3).map((member, i) => {
|
{participants.slice(0, max).map((member, i) => {
|
||||||
const avatarUrl = member.user?.avatarUrl;
|
const avatarUrl = member.user?.avatarUrl;
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
key={member.userId}
|
key={member.userId}
|
||||||
size="xs"
|
size={size}
|
||||||
src={avatarUrl && getAvatarUrl(client, avatarUrl, 22)}
|
src={avatarUrl && getAvatarUrl(client, avatarUrl, _size)}
|
||||||
fallback={member.name.slice(0, 1).toUpperCase()}
|
fallback={member.name.slice(0, 1).toUpperCase()}
|
||||||
className={styles.avatar}
|
className={styles.avatar}
|
||||||
style={{ left: i * 22 }}
|
style={{ left: i * (_size - _overlap) }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{participants.length > 3 && (
|
{participants.length > max && (
|
||||||
<Avatar
|
<Avatar
|
||||||
key="additional"
|
key="additional"
|
||||||
size="xs"
|
size={size}
|
||||||
fallback={`+${participants.length - 3}`}
|
fallback={`+${participants.length - max}`}
|
||||||
className={styles.avatar}
|
className={styles.avatar}
|
||||||
style={{ left: 3 * 22 }}
|
style={{ left: max * (_size - _overlap) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Facepile.defaultProps = {
|
||||||
|
max: 3,
|
||||||
|
size: "xs",
|
||||||
|
};
|
||||||
|
|
|
@ -1,11 +1,26 @@
|
||||||
.facepile {
|
.facepile {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.facepile.xs {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facepile.sm {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facepile.md {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
.facepile .avatar {
|
.facepile .avatar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
border: 1px solid var(--bgColor2);
|
border: 1px solid var(--bgColor2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.facepile.md .avatar {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,8 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
|
||||||
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
|
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
|
||||||
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
|
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
|
||||||
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
|
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
|
||||||
|
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
|
||||||
|
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
|
||||||
import { useButton } from "@react-aria/button";
|
import { useButton } from "@react-aria/button";
|
||||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||||
import { TooltipTrigger } from "../Tooltip";
|
import { TooltipTrigger } from "../Tooltip";
|
||||||
|
@ -20,6 +22,7 @@ export const variantToClassName = {
|
||||||
copy: [styles.copyButton],
|
copy: [styles.copyButton],
|
||||||
iconCopy: [styles.iconCopyButton],
|
iconCopy: [styles.iconCopyButton],
|
||||||
secondaryCopy: [styles.copyButton],
|
secondaryCopy: [styles.copyButton],
|
||||||
|
secondaryHangup: [styles.secondaryHangup],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sizeToClassName = {
|
export const sizeToClassName = {
|
||||||
|
@ -126,3 +129,25 @@ export function HangupButton({ className, ...rest }) {
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SettingsButton({ className, ...rest }) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button variant="toolbar" {...rest}>
|
||||||
|
<SettingsIcon />
|
||||||
|
</Button>
|
||||||
|
{() => "Settings"}
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteButton({ className, ...rest }) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button variant="toolbar" {...rest}>
|
||||||
|
<AddUserIcon />
|
||||||
|
</Button>
|
||||||
|
{() => "Invite"}
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ limitations under the License.
|
||||||
.iconButton,
|
.iconButton,
|
||||||
.iconCopyButton,
|
.iconCopyButton,
|
||||||
.secondary,
|
.secondary,
|
||||||
|
.secondaryHangup,
|
||||||
.copyButton {
|
.copyButton {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -34,6 +35,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary,
|
.secondary,
|
||||||
|
.secondaryHangup,
|
||||||
.button,
|
.button,
|
||||||
.copyButton {
|
.copyButton {
|
||||||
padding: 7px 15px;
|
padding: 7px 15px;
|
||||||
|
@ -53,6 +55,7 @@ limitations under the License.
|
||||||
.iconButton:focus,
|
.iconButton:focus,
|
||||||
.iconCopyButton:focus,
|
.iconCopyButton:focus,
|
||||||
.secondary:focus,
|
.secondary:focus,
|
||||||
|
.secondaryHangup:focus,
|
||||||
.copyButton:focus {
|
.copyButton:focus {
|
||||||
outline: auto;
|
outline: auto;
|
||||||
}
|
}
|
||||||
|
@ -119,6 +122,12 @@ limitations under the License.
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondaryHangup {
|
||||||
|
color: #ff5b55;
|
||||||
|
border: 2px solid #ff5b55;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.copyButton.secondaryCopy {
|
.copyButton.secondaryCopy {
|
||||||
color: var(--textColor1);
|
color: var(--textColor1);
|
||||||
border-color: var(--textColor1);
|
border-color: var(--textColor1);
|
||||||
|
|
|
@ -13,22 +13,25 @@ import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { Headline, Title } from "../typography/Typography";
|
import { Headline, Title } from "../typography/Typography";
|
||||||
import { Form } from "../form/Form";
|
import { Form } from "../form/Form";
|
||||||
|
import { useShouldShowPtt } from "../useShouldShowPtt";
|
||||||
|
|
||||||
export function RegisteredView({ client }) {
|
export function RegisteredView({ client }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState();
|
const [error, setError] = useState();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const shouldShowPtt = useShouldShowPtt();
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = new FormData(e.target);
|
const data = new FormData(e.target);
|
||||||
const roomName = data.get("callName");
|
const roomName = data.get("callName");
|
||||||
|
const ptt = data.get("ptt") !== null;
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const roomIdOrAlias = await createRoom(client, roomName);
|
const roomIdOrAlias = await createRoom(client, roomName, ptt);
|
||||||
|
|
||||||
if (roomIdOrAlias) {
|
if (roomIdOrAlias) {
|
||||||
history.push(`/room/${roomIdOrAlias}`);
|
history.push(`/room/${roomIdOrAlias}`);
|
||||||
|
@ -87,6 +90,7 @@ export function RegisteredView({ client }) {
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
@ -96,6 +100,16 @@ export function RegisteredView({ client }) {
|
||||||
{loading ? "Loading..." : "Go"}
|
{loading ? "Loading..." : "Go"}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
|
{shouldShowPtt && (
|
||||||
|
<FieldRow className={styles.fieldRow}>
|
||||||
|
<InputField
|
||||||
|
id="ptt"
|
||||||
|
name="ptt"
|
||||||
|
label="Push to Talk"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow className={styles.fieldRow}>
|
<FieldRow className={styles.fieldRow}>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage>{error.message}</ErrorMessage>
|
||||||
|
|
|
@ -7,6 +7,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldRow {
|
.fieldRow {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldRow:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,8 +15,10 @@ import { Form } from "../form/Form";
|
||||||
import styles from "./UnauthenticatedView.module.css";
|
import styles from "./UnauthenticatedView.module.css";
|
||||||
import commonStyles from "./common.module.css";
|
import commonStyles from "./common.module.css";
|
||||||
import { generateRandomName } from "../auth/generateRandomName";
|
import { generateRandomName } from "../auth/generateRandomName";
|
||||||
|
import { useShouldShowPtt } from "../useShouldShowPtt";
|
||||||
|
|
||||||
export function UnauthenticatedView() {
|
export function UnauthenticatedView() {
|
||||||
|
const shouldShowPtt = useShouldShowPtt();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState();
|
const [error, setError] = useState();
|
||||||
const [{ privacyPolicyUrl, recaptchaKey }, register] =
|
const [{ privacyPolicyUrl, recaptchaKey }, register] =
|
||||||
|
@ -28,6 +30,7 @@ export function UnauthenticatedView() {
|
||||||
const data = new FormData(e.target);
|
const data = new FormData(e.target);
|
||||||
const roomName = data.get("callName");
|
const roomName = data.get("callName");
|
||||||
const displayName = data.get("displayName");
|
const displayName = data.get("displayName");
|
||||||
|
const ptt = data.get("ptt") !== null;
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
@ -41,7 +44,7 @@ export function UnauthenticatedView() {
|
||||||
recaptchaResponse,
|
recaptchaResponse,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
const roomIdOrAlias = await createRoom(client, roomName);
|
const roomIdOrAlias = await createRoom(client, roomName, ptt);
|
||||||
|
|
||||||
if (roomIdOrAlias) {
|
if (roomIdOrAlias) {
|
||||||
history.push(`/room/${roomIdOrAlias}`);
|
history.push(`/room/${roomIdOrAlias}`);
|
||||||
|
@ -111,6 +114,16 @@ export function UnauthenticatedView() {
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
|
{shouldShowPtt && (
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
id="ptt"
|
||||||
|
name="ptt"
|
||||||
|
label="Push to Talk"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
)}
|
||||||
<Caption>
|
<Caption>
|
||||||
By clicking "Go", you agree to our{" "}
|
By clicking "Go", you agree to our{" "}
|
||||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||||
|
|
41
src/input/Toggle.jsx
Normal file
41
src/input/Toggle.jsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import React, { useCallback, useRef } from "react";
|
||||||
|
import styles from "./Toggle.module.css";
|
||||||
|
import { useToggleButton } from "@react-aria/button";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Field } from "./Input";
|
||||||
|
|
||||||
|
export function Toggle({ id, label, className, onChange, isSelected }) {
|
||||||
|
const buttonRef = useRef();
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
onChange(!isSelected);
|
||||||
|
});
|
||||||
|
const { buttonProps } = useToggleButton(
|
||||||
|
{ isSelected },
|
||||||
|
{ toggle },
|
||||||
|
buttonRef
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
className={classNames(
|
||||||
|
styles.toggle,
|
||||||
|
{ [styles.on]: isSelected },
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
{...buttonProps}
|
||||||
|
ref={buttonRef}
|
||||||
|
id={id}
|
||||||
|
className={classNames(styles.button, {
|
||||||
|
[styles.isPressed]: isSelected,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={styles.ball} />
|
||||||
|
</button>
|
||||||
|
<label className={styles.label} htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
46
src/input/Toggle.module.css
Normal file
46
src/input/Toggle.module.css
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
.toggle {
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 0.2s ease-out 0.1s;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 21px;
|
||||||
|
background-color: #6f7882;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ball {
|
||||||
|
transition: left 0.15s ease-out 0.1s;
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 21px;
|
||||||
|
background-color: #15191e;
|
||||||
|
left: 2px;
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
padding: 10px 8px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: #6f7882;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on .button {
|
||||||
|
background-color: #0dbd8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on .ball {
|
||||||
|
left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on .label {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
|
@ -76,7 +76,7 @@ export function isLocalRoomId(roomId) {
|
||||||
return parts[1] === defaultHomeserverHost;
|
return parts[1] === defaultHomeserverHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRoom(client, name) {
|
export async function createRoom(client, name, isPtt = false) {
|
||||||
const { room_id, room_alias } = await client.createRoom({
|
const { room_id, room_alias } = await client.createRoom({
|
||||||
visibility: "private",
|
visibility: "private",
|
||||||
preset: "public_chat",
|
preset: "public_chat",
|
||||||
|
@ -107,9 +107,12 @@ export async function createRoom(client, name) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log({ isPtt });
|
||||||
|
|
||||||
await client.createGroupCall(
|
await client.createGroupCall(
|
||||||
room_id,
|
room_id,
|
||||||
GroupCallType.Video,
|
isPtt ? GroupCallType.Voice : GroupCallType.Video,
|
||||||
|
isPtt,
|
||||||
GroupCallIntent.Prompt
|
GroupCallIntent.Prompt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
61
src/room/AudioPreview.jsx
Normal file
61
src/room/AudioPreview.jsx
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import React from "react";
|
||||||
|
import styles from "./AudioPreview.module.css";
|
||||||
|
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
|
import { SelectInput } from "../input/SelectInput";
|
||||||
|
import { Item } from "@react-stately/collections";
|
||||||
|
import { Body } from "../typography/Typography";
|
||||||
|
|
||||||
|
export function AudioPreview({
|
||||||
|
state,
|
||||||
|
roomName,
|
||||||
|
audioInput,
|
||||||
|
audioInputs,
|
||||||
|
setAudioInput,
|
||||||
|
audioOutput,
|
||||||
|
audioOutputs,
|
||||||
|
setAudioOutput,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>{`${roomName} - Radio Call`}</h1>
|
||||||
|
<div className={styles.preview}>
|
||||||
|
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||||
|
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
|
||||||
|
Microphone permissions needed to join the call.
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
|
{state === GroupCallState.InitializingLocalCallFeed && (
|
||||||
|
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
|
||||||
|
Accept microphone permissions to join the call.
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
|
{state === GroupCallState.LocalCallFeedInitialized && (
|
||||||
|
<>
|
||||||
|
<SelectInput
|
||||||
|
label="Microphone"
|
||||||
|
selectedKey={audioInput}
|
||||||
|
onSelectionChange={setAudioInput}
|
||||||
|
className={styles.inputField}
|
||||||
|
>
|
||||||
|
{audioInputs.map(({ deviceId, label }) => (
|
||||||
|
<Item key={deviceId}>{label}</Item>
|
||||||
|
))}
|
||||||
|
</SelectInput>
|
||||||
|
{audioOutputs.length > 0 && (
|
||||||
|
<SelectInput
|
||||||
|
label="Speaker"
|
||||||
|
selectedKey={audioOutput}
|
||||||
|
onSelectionChange={setAudioOutput}
|
||||||
|
className={styles.inputField}
|
||||||
|
>
|
||||||
|
{audioOutputs.map(({ deviceId, label }) => (
|
||||||
|
<Item key={deviceId}>{label}</Item>
|
||||||
|
))}
|
||||||
|
</SelectInput>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
27
src/room/AudioPreview.module.css
Normal file
27
src/room/AudioPreview.module.css
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
.preview {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
max-width: 414px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputField {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputField:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.microphonePermissions {
|
||||||
|
margin: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.preview {
|
||||||
|
margin-top: 40px;
|
||||||
|
background-color: #21262c;
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import { useGroupCall } from "./useGroupCall";
|
||||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { InCallView } from "./InCallView";
|
import { InCallView } from "./InCallView";
|
||||||
|
import { PTTCallView } from "./PTTCallView";
|
||||||
import { CallEndedView } from "./CallEndedView";
|
import { CallEndedView } from "./CallEndedView";
|
||||||
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
|
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
|
||||||
import { useLocationNavigation } from "../useLocationNavigation";
|
import { useLocationNavigation } from "../useLocationNavigation";
|
||||||
|
@ -47,6 +48,7 @@ export function GroupCallView({
|
||||||
localScreenshareFeed,
|
localScreenshareFeed,
|
||||||
screenshareFeeds,
|
screenshareFeeds,
|
||||||
hasLocalParticipant,
|
hasLocalParticipant,
|
||||||
|
participants,
|
||||||
} = useGroupCall(groupCall);
|
} = useGroupCall(groupCall);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -72,27 +74,43 @@ export function GroupCallView({
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorView error={error} />;
|
return <ErrorView error={error} />;
|
||||||
} else if (state === GroupCallState.Entered) {
|
} else if (state === GroupCallState.Entered) {
|
||||||
return (
|
if (groupCall.isPtt) {
|
||||||
<InCallView
|
return (
|
||||||
groupCall={groupCall}
|
<PTTCallView
|
||||||
client={client}
|
client={client}
|
||||||
roomName={groupCall.room.name}
|
roomId={roomId}
|
||||||
microphoneMuted={microphoneMuted}
|
roomName={groupCall.room.name}
|
||||||
localVideoMuted={localVideoMuted}
|
groupCall={groupCall}
|
||||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
participants={participants}
|
||||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
userMediaFeeds={userMediaFeeds}
|
||||||
userMediaFeeds={userMediaFeeds}
|
onLeave={onLeave}
|
||||||
activeSpeaker={activeSpeaker}
|
setShowInspector={onChangeShowInspector}
|
||||||
onLeave={onLeave}
|
showInspector={showInspector}
|
||||||
toggleScreensharing={toggleScreensharing}
|
/>
|
||||||
isScreensharing={isScreensharing}
|
);
|
||||||
localScreenshareFeed={localScreenshareFeed}
|
} else {
|
||||||
screenshareFeeds={screenshareFeeds}
|
return (
|
||||||
setShowInspector={onChangeShowInspector}
|
<InCallView
|
||||||
showInspector={showInspector}
|
groupCall={groupCall}
|
||||||
roomId={roomId}
|
client={client}
|
||||||
/>
|
roomName={groupCall.room.name}
|
||||||
);
|
microphoneMuted={microphoneMuted}
|
||||||
|
localVideoMuted={localVideoMuted}
|
||||||
|
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||||
|
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||||
|
userMediaFeeds={userMediaFeeds}
|
||||||
|
activeSpeaker={activeSpeaker}
|
||||||
|
onLeave={onLeave}
|
||||||
|
toggleScreensharing={toggleScreensharing}
|
||||||
|
isScreensharing={isScreensharing}
|
||||||
|
localScreenshareFeed={localScreenshareFeed}
|
||||||
|
screenshareFeeds={screenshareFeeds}
|
||||||
|
setShowInspector={onChangeShowInspector}
|
||||||
|
showInspector={showInspector}
|
||||||
|
roomId={roomId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (state === GroupCallState.Entering) {
|
} else if (state === GroupCallState.Entering) {
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
|
@ -105,6 +123,7 @@ export function GroupCallView({
|
||||||
return (
|
return (
|
||||||
<LobbyView
|
<LobbyView
|
||||||
client={client}
|
client={client}
|
||||||
|
groupCall={groupCall}
|
||||||
hasLocalParticipant={hasLocalParticipant}
|
hasLocalParticipant={hasLocalParticipant}
|
||||||
roomName={groupCall.room.name}
|
roomName={groupCall.room.name}
|
||||||
state={state}
|
state={state}
|
||||||
|
|
|
@ -1,23 +1,20 @@
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import styles from "./LobbyView.module.css";
|
import styles from "./LobbyView.module.css";
|
||||||
import { Button, CopyButton, MicButton, VideoButton } from "../button";
|
import { Button, CopyButton } from "../button";
|
||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { useCallFeed } from "../video-grid/useCallFeed";
|
import { useCallFeed } from "../video-grid/useCallFeed";
|
||||||
import { useMediaStream } from "../video-grid/useMediaStream";
|
|
||||||
import { getRoomUrl } from "../matrix-utils";
|
import { getRoomUrl } from "../matrix-utils";
|
||||||
import { OverflowMenu } from "./OverflowMenu";
|
|
||||||
import { UserMenuContainer } from "../UserMenuContainer";
|
import { UserMenuContainer } from "../UserMenuContainer";
|
||||||
import { Body, Link } from "../typography/Typography";
|
import { Body, Link } from "../typography/Typography";
|
||||||
import { Avatar } from "../Avatar";
|
|
||||||
import { useProfile } from "../profile/useProfile";
|
|
||||||
import useMeasure from "react-use-measure";
|
|
||||||
import { ResizeObserver } from "@juggle/resize-observer";
|
|
||||||
import { useLocationNavigation } from "../useLocationNavigation";
|
import { useLocationNavigation } from "../useLocationNavigation";
|
||||||
import { useMediaHandler } from "../settings/useMediaHandler";
|
import { useMediaHandler } from "../settings/useMediaHandler";
|
||||||
|
import { VideoPreview } from "./VideoPreview";
|
||||||
|
import { AudioPreview } from "./AudioPreview";
|
||||||
|
|
||||||
export function LobbyView({
|
export function LobbyView({
|
||||||
client,
|
client,
|
||||||
|
groupCall,
|
||||||
roomName,
|
roomName,
|
||||||
state,
|
state,
|
||||||
onInitLocalCallFeed,
|
onInitLocalCallFeed,
|
||||||
|
@ -32,11 +29,14 @@ export function LobbyView({
|
||||||
roomId,
|
roomId,
|
||||||
}) {
|
}) {
|
||||||
const { stream } = useCallFeed(localCallFeed);
|
const { stream } = useCallFeed(localCallFeed);
|
||||||
const { audioOutput } = useMediaHandler();
|
const {
|
||||||
const videoRef = useMediaStream(stream, audioOutput, true);
|
audioInput,
|
||||||
const { displayName, avatarUrl } = useProfile(client);
|
audioInputs,
|
||||||
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
|
setAudioInput,
|
||||||
const avatarSize = (previewBounds.height - 66) / 2;
|
audioOutput,
|
||||||
|
audioOutputs,
|
||||||
|
setAudioOutput,
|
||||||
|
} = useMediaHandler();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onInitLocalCallFeed();
|
onInitLocalCallFeed();
|
||||||
|
@ -64,53 +64,31 @@ export function LobbyView({
|
||||||
</Header>
|
</Header>
|
||||||
<div className={styles.joinRoom}>
|
<div className={styles.joinRoom}>
|
||||||
<div className={styles.joinRoomContent}>
|
<div className={styles.joinRoomContent}>
|
||||||
<div className={styles.preview} ref={previewRef}>
|
{groupCall.isPtt ? (
|
||||||
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
<AudioPreview
|
||||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
roomName={roomName}
|
||||||
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
|
state={state}
|
||||||
Webcam/microphone permissions needed to join the call.
|
audioInput={audioInput}
|
||||||
</Body>
|
audioInputs={audioInputs}
|
||||||
)}
|
setAudioInput={setAudioInput}
|
||||||
{state === GroupCallState.InitializingLocalCallFeed && (
|
audioOutput={audioOutput}
|
||||||
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
|
audioOutputs={audioOutputs}
|
||||||
Accept webcam/microphone permissions to join the call.
|
setAudioOutput={setAudioOutput}
|
||||||
</Body>
|
/>
|
||||||
)}
|
) : (
|
||||||
{state === GroupCallState.LocalCallFeedInitialized && (
|
<VideoPreview
|
||||||
<>
|
state={state}
|
||||||
{localVideoMuted && (
|
client={client}
|
||||||
<div className={styles.avatarContainer}>
|
microphoneMuted={microphoneMuted}
|
||||||
<Avatar
|
localVideoMuted={localVideoMuted}
|
||||||
style={{
|
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||||
width: avatarSize,
|
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||||
height: avatarSize,
|
setShowInspector={setShowInspector}
|
||||||
borderRadius: avatarSize,
|
showInspector={showInspector}
|
||||||
fontSize: Math.round(avatarSize / 2),
|
stream={stream}
|
||||||
}}
|
audioOutput={audioOutput}
|
||||||
src={avatarUrl}
|
/>
|
||||||
fallback={displayName.slice(0, 1).toUpperCase()}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.previewButtons}>
|
|
||||||
<MicButton
|
|
||||||
muted={microphoneMuted}
|
|
||||||
onPress={toggleMicrophoneMuted}
|
|
||||||
/>
|
|
||||||
<VideoButton
|
|
||||||
muted={localVideoMuted}
|
|
||||||
onPress={toggleLocalVideoMuted}
|
|
||||||
/>
|
|
||||||
<OverflowMenu
|
|
||||||
roomId={roomId}
|
|
||||||
setShowInspector={setShowInspector}
|
|
||||||
showInspector={showInspector}
|
|
||||||
client={client}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
ref={joinCallButtonRef}
|
ref={joinCallButtonRef}
|
||||||
className={styles.copyButton}
|
className={styles.copyButton}
|
||||||
|
|
|
@ -46,58 +46,6 @@ limitations under the License.
|
||||||
margin-top: 50px;
|
margin-top: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview {
|
|
||||||
position: relative;
|
|
||||||
min-height: 280px;
|
|
||||||
height: 50vh;
|
|
||||||
border-radius: 24px;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--bgColor3);
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview video {
|
|
||||||
width: calc(100% + 1px);
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
background-color: black;
|
|
||||||
/* transform scale doesn't perfectly match width, so make -1.01 border issues */
|
|
||||||
transform: scaleX(-1.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarContainer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 66px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: var(--bgColor3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.webcamPermissions {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewButtons {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 66px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: rgba(23, 25, 28, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.joinCallButton {
|
.joinCallButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -118,17 +66,3 @@ limitations under the License.
|
||||||
.copyButton:last-child {
|
.copyButton:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewButtons > * {
|
|
||||||
margin-right: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewButtons > :last-child {
|
|
||||||
margin-right: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
|
||||||
.preview {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
48
src/room/PTTButton.jsx
Normal file
48
src/room/PTTButton.jsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import React from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import styles from "./PTTButton.module.css";
|
||||||
|
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||||
|
import { Avatar } from "../Avatar";
|
||||||
|
|
||||||
|
export function PTTButton({
|
||||||
|
showTalkOverError,
|
||||||
|
activeSpeakerUserId,
|
||||||
|
activeSpeakerDisplayName,
|
||||||
|
activeSpeakerAvatarUrl,
|
||||||
|
activeSpeakerIsLocalUser,
|
||||||
|
size,
|
||||||
|
startTalking,
|
||||||
|
stopTalking,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={classNames(styles.pttButton, {
|
||||||
|
[styles.talking]: activeSpeakerUserId,
|
||||||
|
[styles.error]: showTalkOverError,
|
||||||
|
})}
|
||||||
|
onMouseDown={startTalking}
|
||||||
|
onMouseUp={stopTalking}
|
||||||
|
>
|
||||||
|
{activeSpeakerIsLocalUser || !activeSpeakerUserId ? (
|
||||||
|
<MicIcon
|
||||||
|
className={styles.micIcon}
|
||||||
|
width={size / 3}
|
||||||
|
height={size / 3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar
|
||||||
|
key={activeSpeakerUserId}
|
||||||
|
style={{
|
||||||
|
width: size - 12,
|
||||||
|
height: size - 12,
|
||||||
|
borderRadius: size - 12,
|
||||||
|
fontSize: Math.round((size - 12) / 2),
|
||||||
|
}}
|
||||||
|
src={activeSpeakerAvatarUrl}
|
||||||
|
fallback={activeSpeakerDisplayName.slice(0, 1).toUpperCase()}
|
||||||
|
className={styles.avatar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
25
src/room/PTTButton.module.css
Normal file
25
src/room/PTTButton.module.css
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
.pttButton {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 232px;
|
||||||
|
max-width: 232px;
|
||||||
|
border-radius: 116px;
|
||||||
|
color: ##fff;
|
||||||
|
border: 6px solid #0dbd8b;
|
||||||
|
background-color: #21262c;
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talking {
|
||||||
|
background-color: #0dbd8b;
|
||||||
|
box-shadow: 0px 0px 0px 17px rgba(13, 189, 139, 0.2),
|
||||||
|
0px 0px 0px 34px rgba(13, 189, 139, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: #ff5b55;
|
||||||
|
border-color: #ff5b55;
|
||||||
|
box-shadow: 0px 0px 0px 17px rgba(255, 91, 85, 0.2),
|
||||||
|
0px 0px 0px 34px rgba(255, 91, 85, 0.2);
|
||||||
|
}
|
164
src/room/PTTCallView.jsx
Normal file
164
src/room/PTTCallView.jsx
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useModalTriggerState } from "../Modal";
|
||||||
|
import { SettingsModal } from "../settings/SettingsModal";
|
||||||
|
import { InviteModal } from "./InviteModal";
|
||||||
|
import { HangupButton, InviteButton, SettingsButton } from "../button";
|
||||||
|
import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
|
||||||
|
import styles from "./PTTCallView.module.css";
|
||||||
|
import { Facepile } from "../Facepile";
|
||||||
|
import { PTTButton } from "./PTTButton";
|
||||||
|
import { PTTFeed } from "./PTTFeed";
|
||||||
|
import { useMediaHandler } from "../settings/useMediaHandler";
|
||||||
|
import useMeasure from "react-use-measure";
|
||||||
|
import { ResizeObserver } from "@juggle/resize-observer";
|
||||||
|
import { usePTT } from "./usePTT";
|
||||||
|
import { Timer } from "./Timer";
|
||||||
|
import { Toggle } from "../input/Toggle";
|
||||||
|
import { getAvatarUrl } from "../matrix-utils";
|
||||||
|
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
|
||||||
|
|
||||||
|
export function PTTCallView({
|
||||||
|
client,
|
||||||
|
roomId,
|
||||||
|
roomName,
|
||||||
|
groupCall,
|
||||||
|
participants,
|
||||||
|
userMediaFeeds,
|
||||||
|
onLeave,
|
||||||
|
setShowInspector,
|
||||||
|
showInspector,
|
||||||
|
}) {
|
||||||
|
const { modalState: inviteModalState, modalProps: inviteModalProps } =
|
||||||
|
useModalTriggerState();
|
||||||
|
const { modalState: settingsModalState, modalProps: settingsModalProps } =
|
||||||
|
useModalTriggerState();
|
||||||
|
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
|
||||||
|
const facepileSize = bounds.width < 800 ? "sm" : "md";
|
||||||
|
const pttButtonSize = 232;
|
||||||
|
const pttBorderWidth = 6;
|
||||||
|
|
||||||
|
const { audioOutput } = useMediaHandler();
|
||||||
|
|
||||||
|
const {
|
||||||
|
pttButtonHeld,
|
||||||
|
isAdmin,
|
||||||
|
talkOverEnabled,
|
||||||
|
setTalkOverEnabled,
|
||||||
|
activeSpeakerUserId,
|
||||||
|
startTalking,
|
||||||
|
stopTalking,
|
||||||
|
} = usePTT(client, groupCall, userMediaFeeds);
|
||||||
|
|
||||||
|
const activeSpeakerIsLocalUser =
|
||||||
|
activeSpeakerUserId && client.getUserId() === activeSpeakerUserId;
|
||||||
|
const showTalkOverError =
|
||||||
|
pttButtonHeld && !activeSpeakerIsLocalUser && !talkOverEnabled;
|
||||||
|
const activeSpeakerUser = activeSpeakerUserId
|
||||||
|
? client.getUser(activeSpeakerUserId)
|
||||||
|
: null;
|
||||||
|
const activeSpeakerAvatarUrl = activeSpeakerUser
|
||||||
|
? getAvatarUrl(
|
||||||
|
client,
|
||||||
|
activeSpeakerUser.avatarUrl,
|
||||||
|
pttButtonSize - pttBorderWidth * 2
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const activeSpeakerDisplayName = activeSpeakerUser
|
||||||
|
? activeSpeakerUser.displayName
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.pttCallView} ref={containerRef}>
|
||||||
|
<Header className={styles.header}>
|
||||||
|
<LeftNav>
|
||||||
|
<RoomSetupHeaderInfo roomName={roomName} onPress={onLeave} />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav />
|
||||||
|
</Header>
|
||||||
|
<div className={styles.center}>
|
||||||
|
<div className={styles.participants}>
|
||||||
|
<p>{`${participants.length} ${
|
||||||
|
participants.length > 1 ? "people" : "person"
|
||||||
|
} connected`}</p>
|
||||||
|
<Facepile
|
||||||
|
size={facepileSize}
|
||||||
|
max={8}
|
||||||
|
className={styles.facepile}
|
||||||
|
client={client}
|
||||||
|
participants={participants}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<SettingsButton onPress={() => settingsModalState.open()} />
|
||||||
|
<HangupButton onPress={onLeave} />
|
||||||
|
<InviteButton onPress={() => inviteModalState.open()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.pttButtonContainer}>
|
||||||
|
{activeSpeakerUserId ? (
|
||||||
|
<div className={styles.talkingInfo}>
|
||||||
|
<h2>
|
||||||
|
{!activeSpeakerIsLocalUser && (
|
||||||
|
<AudioIcon className={styles.speakerIcon} />
|
||||||
|
)}
|
||||||
|
{activeSpeakerIsLocalUser
|
||||||
|
? "Talking..."
|
||||||
|
: `${activeSpeakerDisplayName} is talking...`}
|
||||||
|
</h2>
|
||||||
|
<Timer value={activeSpeakerUserId} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.talkingInfo} />
|
||||||
|
)}
|
||||||
|
<PTTButton
|
||||||
|
showTalkOverError={showTalkOverError}
|
||||||
|
activeSpeakerUserId={activeSpeakerUserId}
|
||||||
|
activeSpeakerDisplayName={activeSpeakerDisplayName}
|
||||||
|
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
|
||||||
|
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
|
||||||
|
size={pttButtonSize}
|
||||||
|
startTalking={startTalking}
|
||||||
|
stopTalking={stopTalking}
|
||||||
|
/>
|
||||||
|
<p className={styles.actionTip}>
|
||||||
|
{showTalkOverError
|
||||||
|
? "You can't talk at the same time"
|
||||||
|
: pttButtonHeld
|
||||||
|
? "Release spacebar key to stop"
|
||||||
|
: talkOverEnabled &&
|
||||||
|
activeSpeakerUserId &&
|
||||||
|
!activeSpeakerIsLocalUser
|
||||||
|
? `Press and hold spacebar to talk over ${activeSpeakerDisplayName}`
|
||||||
|
: "Press and hold spacebar to talk"}
|
||||||
|
</p>
|
||||||
|
{userMediaFeeds.map((callFeed) => (
|
||||||
|
<PTTFeed
|
||||||
|
key={callFeed.userId}
|
||||||
|
callFeed={callFeed}
|
||||||
|
audioOutputDevice={audioOutput}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{isAdmin && (
|
||||||
|
<Toggle
|
||||||
|
isSelected={talkOverEnabled}
|
||||||
|
onChange={setTalkOverEnabled}
|
||||||
|
label="Talk over speaker"
|
||||||
|
id="talkOverEnabled"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settingsModalState.isOpen && (
|
||||||
|
<SettingsModal
|
||||||
|
{...settingsModalProps}
|
||||||
|
setShowInspector={setShowInspector}
|
||||||
|
showInspector={showInspector}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{inviteModalState.isOpen && (
|
||||||
|
<InviteModal roomId={roomId} {...inviteModalProps} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
106
src/room/PTTCallView.module.css
Normal file
106
src/room/PTTCallView.module.css
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
.pttCallView {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100%;
|
||||||
|
position: fixed;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants > p {
|
||||||
|
color: #a9b2bc;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facepile {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkingInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speakerIcon {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pttButtonContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionTip {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer > * {
|
||||||
|
margin-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer > :last-child {
|
||||||
|
margin-right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.participants {
|
||||||
|
margin-bottom: 67px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkingInfo {
|
||||||
|
margin-bottom: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionTip {
|
||||||
|
margin-top: 42px;
|
||||||
|
margin-bottom: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pttButtonContainer {
|
||||||
|
flex: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex: auto;
|
||||||
|
order: 4;
|
||||||
|
}
|
||||||
|
}
|
10
src/room/PTTFeed.jsx
Normal file
10
src/room/PTTFeed.jsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useCallFeed } from "../video-grid/useCallFeed";
|
||||||
|
import { useMediaStream } from "../video-grid/useMediaStream";
|
||||||
|
import styles from "./PTTFeed.module.css";
|
||||||
|
|
||||||
|
export function PTTFeed({ callFeed, audioOutputDevice }) {
|
||||||
|
const { isLocal, stream } = useCallFeed(callFeed);
|
||||||
|
const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
|
||||||
|
return <audio ref={mediaRef} className={styles.audioFeed} playsInline />;
|
||||||
|
}
|
3
src/room/PTTFeed.module.css
Normal file
3
src/room/PTTFeed.module.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.audioFeed {
|
||||||
|
display: none;
|
||||||
|
}
|
37
src/room/Timer.jsx
Normal file
37
src/room/Timer.jsx
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function leftPad(value) {
|
||||||
|
return value < 10 ? "0" + value : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(msElapsed) {
|
||||||
|
const secondsElapsed = msElapsed / 1000;
|
||||||
|
const hours = Math.floor(secondsElapsed / 3600);
|
||||||
|
const minutes = Math.floor(secondsElapsed / 60) - hours * 60;
|
||||||
|
const seconds = Math.floor(secondsElapsed - hours * 3600 - minutes * 60);
|
||||||
|
return `${leftPad(hours)}:${leftPad(minutes)}:${leftPad(seconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Timer({ value }) {
|
||||||
|
const [timestamp, setTimestamp] = useState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startTimeMs = performance.now();
|
||||||
|
|
||||||
|
let animationFrame;
|
||||||
|
|
||||||
|
function onUpdate(curTimeMs) {
|
||||||
|
const msElapsed = curTimeMs - startTimeMs;
|
||||||
|
setTimestamp(formatTime(msElapsed));
|
||||||
|
animationFrame = requestAnimationFrame(onUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(startTimeMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
};
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return <p>{timestamp}</p>;
|
||||||
|
}
|
80
src/room/VideoPreview.jsx
Normal file
80
src/room/VideoPreview.jsx
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import React from "react";
|
||||||
|
import { MicButton, VideoButton } from "../button";
|
||||||
|
import { useMediaStream } from "../video-grid/useMediaStream";
|
||||||
|
import { OverflowMenu } from "./OverflowMenu";
|
||||||
|
import { Avatar } from "../Avatar";
|
||||||
|
import { useProfile } from "../profile/useProfile";
|
||||||
|
import useMeasure from "react-use-measure";
|
||||||
|
import { ResizeObserver } from "@juggle/resize-observer";
|
||||||
|
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
|
import styles from "./VideoPreview.module.css";
|
||||||
|
import { Body } from "../typography/Typography";
|
||||||
|
|
||||||
|
export function VideoPreview({
|
||||||
|
client,
|
||||||
|
state,
|
||||||
|
roomId,
|
||||||
|
microphoneMuted,
|
||||||
|
localVideoMuted,
|
||||||
|
toggleLocalVideoMuted,
|
||||||
|
toggleMicrophoneMuted,
|
||||||
|
setShowInspector,
|
||||||
|
showInspector,
|
||||||
|
audioOutput,
|
||||||
|
stream,
|
||||||
|
}) {
|
||||||
|
const videoRef = useMediaStream(stream, audioOutput, true);
|
||||||
|
const { displayName, avatarUrl } = useProfile(client);
|
||||||
|
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
|
||||||
|
const avatarSize = (previewBounds.height - 66) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.preview} ref={previewRef}>
|
||||||
|
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
||||||
|
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||||
|
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
|
||||||
|
Webcam/microphone permissions needed to join the call.
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
|
{state === GroupCallState.InitializingLocalCallFeed && (
|
||||||
|
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
|
||||||
|
Accept webcam/microphone permissions to join the call.
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
|
{state === GroupCallState.LocalCallFeedInitialized && (
|
||||||
|
<>
|
||||||
|
{localVideoMuted && (
|
||||||
|
<div className={styles.avatarContainer}>
|
||||||
|
<Avatar
|
||||||
|
style={{
|
||||||
|
width: avatarSize,
|
||||||
|
height: avatarSize,
|
||||||
|
borderRadius: avatarSize,
|
||||||
|
fontSize: Math.round(avatarSize / 2),
|
||||||
|
}}
|
||||||
|
src={avatarUrl}
|
||||||
|
fallback={displayName.slice(0, 1).toUpperCase()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.previewButtons}>
|
||||||
|
<MicButton
|
||||||
|
muted={microphoneMuted}
|
||||||
|
onPress={toggleMicrophoneMuted}
|
||||||
|
/>
|
||||||
|
<VideoButton
|
||||||
|
muted={localVideoMuted}
|
||||||
|
onPress={toggleLocalVideoMuted}
|
||||||
|
/>
|
||||||
|
<OverflowMenu
|
||||||
|
roomId={roomId}
|
||||||
|
setShowInspector={setShowInspector}
|
||||||
|
showInspector={showInspector}
|
||||||
|
client={client}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
65
src/room/VideoPreview.module.css
Normal file
65
src/room/VideoPreview.module.css
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
.preview {
|
||||||
|
position: relative;
|
||||||
|
min-height: 280px;
|
||||||
|
height: 50vh;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--bgColor3);
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview video {
|
||||||
|
width: calc(100% + 1px);
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background-color: black;
|
||||||
|
/* transform scale doesn't perfectly match width, so make -1.01 border issues */
|
||||||
|
transform: scaleX(-1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 66px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--bgColor3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcamPermissions {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewButtons {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 66px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(23, 25, 28, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewButtons > * {
|
||||||
|
margin-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewButtons > :last-child {
|
||||||
|
margin-right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.preview {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
}
|
121
src/room/usePTT.js
Normal file
121
src/room/usePTT.js
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function usePTT(client, groupCall, userMediaFeeds) {
|
||||||
|
const [
|
||||||
|
{ pttButtonHeld, isAdmin, talkOverEnabled, activeSpeakerUserId },
|
||||||
|
setState,
|
||||||
|
] = useState(() => {
|
||||||
|
const roomMember = groupCall.room.getMember(client.getUserId());
|
||||||
|
|
||||||
|
const activeSpeakerFeed = userMediaFeeds.find((f) => !f.isAudioMuted());
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin: roomMember.powerLevel >= 100,
|
||||||
|
talkOverEnabled: false,
|
||||||
|
pttButtonHeld: false,
|
||||||
|
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onMuteStateChanged(...args) {
|
||||||
|
const activeSpeakerFeed = userMediaFeeds.find((f) => !f.isAudioMuted());
|
||||||
|
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
activeSpeakerUserId: activeSpeakerFeed
|
||||||
|
? activeSpeakerFeed.userId
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const callFeed of userMediaFeeds) {
|
||||||
|
callFeed.addListener("mute_state_changed", onMuteStateChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSpeakerFeed = userMediaFeeds.find((f) => !f.isAudioMuted());
|
||||||
|
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const callFeed of userMediaFeeds) {
|
||||||
|
callFeed.removeListener("mute_state_changed", onMuteStateChanged);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [userMediaFeeds]);
|
||||||
|
|
||||||
|
const startTalking = useCallback(() => {
|
||||||
|
if (!activeSpeakerUserId || isAdmin || talkOverEnabled) {
|
||||||
|
if (groupCall.isMicrophoneMuted()) {
|
||||||
|
groupCall.setMicrophoneMuted(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prevState) => ({ ...prevState, pttButtonHeld: true }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopTalking = useCallback(() => {
|
||||||
|
if (!groupCall.isMicrophoneMuted()) {
|
||||||
|
groupCall.setMicrophoneMuted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prevState) => ({ ...prevState, pttButtonHeld: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event) {
|
||||||
|
if (event.code === "Space") {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
startTalking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(event) {
|
||||||
|
if (event.code === "Space") {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
stopTalking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
// TODO: We will need to disable this for a global PTT hotkey to work
|
||||||
|
if (!groupCall.isMicrophoneMuted()) {
|
||||||
|
groupCall.setMicrophoneMuted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prevState) => ({ ...prevState, pttButtonHeld: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("keyup", onKeyUp);
|
||||||
|
window.addEventListener("blur", onBlur);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("keyup", onKeyUp);
|
||||||
|
window.removeEventListener("blur", onBlur);
|
||||||
|
};
|
||||||
|
}, [activeSpeakerUserId, isAdmin, talkOverEnabled]);
|
||||||
|
|
||||||
|
const setTalkOverEnabled = useCallback((talkOverEnabled) => {
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
talkOverEnabled,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pttButtonHeld,
|
||||||
|
isAdmin,
|
||||||
|
talkOverEnabled,
|
||||||
|
setTalkOverEnabled,
|
||||||
|
activeSpeakerUserId,
|
||||||
|
startTalking,
|
||||||
|
stopTalking,
|
||||||
|
};
|
||||||
|
}
|
6
src/useShouldShowPtt.js
Normal file
6
src/useShouldShowPtt.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
export function useShouldShowPtt() {
|
||||||
|
const { hash } = useLocation();
|
||||||
|
return hash.startsWith("#ptt");
|
||||||
|
}
|
|
@ -8407,8 +8407,8 @@ markdown-to-jsx@^7.1.3:
|
||||||
integrity sha512-1wrIGZYwIG2gR3yfRmbr4FlQmhaAKoKTpRo4wur4fp9p0njU1Hi7vR8fj0AUKKIcPduiJmPprzmCB5B/GvlC7g==
|
integrity sha512-1wrIGZYwIG2gR3yfRmbr4FlQmhaAKoKTpRo4wur4fp9p0njU1Hi7vR8fj0AUKKIcPduiJmPprzmCB5B/GvlC7g==
|
||||||
|
|
||||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#robertlong/group-call":
|
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#robertlong/group-call":
|
||||||
version "13.0.0"
|
version "15.1.0"
|
||||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ba57736bf6d6d99342c64cca5eb6156ee7b9e178"
|
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8d9cd0fcb37188f63a938f4dd16e1f4e967cf1b6"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.12.5"
|
"@babel/runtime" "^7.12.5"
|
||||||
another-json "^0.2.0"
|
another-json "^0.2.0"
|
||||||
|
|
Loading…
Add table
Reference in a new issue