diff --git a/.vscode/settings.json b/.vscode/settings.json index a681c9ef..6889f44c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,4 +2,4 @@ "editor.formatOnSave": true, "editor.insertSpaces": true, "editor.tabSize": 2 -} \ No newline at end of file +} diff --git a/package.json b/package.json index 23911e66..1cd926f6 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build-storybook": "build-storybook", "prettier:check": "prettier -c src", "prettier:format": "prettier -w src", - "lint": "eslint --max-warnings 8 src" + "lint": "eslint --max-warnings 11 src" }, "dependencies": { "@juggle/resize-observer": "^3.3.1", diff --git a/src/Facepile.jsx b/src/Facepile.jsx index 53b0b948..830a63d5 100644 --- a/src/Facepile.jsx +++ b/src/Facepile.jsx @@ -4,35 +4,63 @@ import classNames from "classnames"; import { Avatar } from "./Avatar"; 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 (
member.name).join(", ")} + style={{ width: participants.length * (_size - _overlap) + _overlap }} {...rest} > - {participants.slice(0, 3).map((member, i) => { + {participants.slice(0, max).map((member, i) => { const avatarUrl = member.user?.avatarUrl; return ( ); })} - {participants.length > 3 && ( + {participants.length > max && ( )}
); } + +Facepile.defaultProps = { + max: 3, + size: "xs", +}; diff --git a/src/Facepile.module.css b/src/Facepile.module.css index 4add90ec..bc5595a2 100644 --- a/src/Facepile.module.css +++ b/src/Facepile.module.css @@ -1,11 +1,26 @@ .facepile { width: 100%; - height: 24px; position: relative; } +.facepile.xs { + height: 24px; +} + +.facepile.sm { + height: 32px; +} + +.facepile.md { + height: 36px; +} + .facepile .avatar { position: absolute; top: 0; border: 1px solid var(--bgColor2); } + +.facepile.md .avatar { + border-width: 2px; +} diff --git a/src/button/Button.jsx b/src/button/Button.jsx index b6c3df98..1e31fde7 100644 --- a/src/button/Button.jsx +++ b/src/button/Button.jsx @@ -7,6 +7,8 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg"; import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg"; import { ReactComponent as HangupIcon } from "../icons/Hangup.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 { mergeProps, useObjectRef } from "@react-aria/utils"; import { TooltipTrigger } from "../Tooltip"; @@ -20,6 +22,7 @@ export const variantToClassName = { copy: [styles.copyButton], iconCopy: [styles.iconCopyButton], secondaryCopy: [styles.copyButton], + secondaryHangup: [styles.secondaryHangup], }; export const sizeToClassName = { @@ -126,3 +129,25 @@ export function HangupButton({ className, ...rest }) { ); } + +export function SettingsButton({ className, ...rest }) { + return ( + + + {() => "Settings"} + + ); +} + +export function InviteButton({ className, ...rest }) { + return ( + + + {() => "Invite"} + + ); +} diff --git a/src/button/Button.module.css b/src/button/Button.module.css index 72450d51..5c3ec300 100644 --- a/src/button/Button.module.css +++ b/src/button/Button.module.css @@ -20,6 +20,7 @@ limitations under the License. .iconButton, .iconCopyButton, .secondary, +.secondaryHangup, .copyButton { position: relative; display: flex; @@ -34,6 +35,7 @@ limitations under the License. } .secondary, +.secondaryHangup, .button, .copyButton { padding: 7px 15px; @@ -53,6 +55,7 @@ limitations under the License. .iconButton:focus, .iconCopyButton:focus, .secondary:focus, +.secondaryHangup:focus, .copyButton:focus { outline: auto; } @@ -119,6 +122,12 @@ limitations under the License. background-color: transparent; } +.secondaryHangup { + color: #ff5b55; + border: 2px solid #ff5b55; + background-color: transparent; +} + .copyButton.secondaryCopy { color: var(--textColor1); border-color: var(--textColor1); diff --git a/src/home/RegisteredView.jsx b/src/home/RegisteredView.jsx index 6e43f205..0480050c 100644 --- a/src/home/RegisteredView.jsx +++ b/src/home/RegisteredView.jsx @@ -13,22 +13,25 @@ import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { useHistory } from "react-router-dom"; import { Headline, Title } from "../typography/Typography"; import { Form } from "../form/Form"; +import { useShouldShowPtt } from "../useShouldShowPtt"; export function RegisteredView({ client }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(); const history = useHistory(); + const shouldShowPtt = useShouldShowPtt(); const onSubmit = useCallback( (e) => { e.preventDefault(); const data = new FormData(e.target); const roomName = data.get("callName"); + const ptt = data.get("ptt") !== null; async function submit() { setError(undefined); setLoading(true); - const roomIdOrAlias = await createRoom(client, roomName); + const roomIdOrAlias = await createRoom(client, roomName, ptt); if (roomIdOrAlias) { history.push(`/room/${roomIdOrAlias}`); @@ -87,6 +90,7 @@ export function RegisteredView({ client }) { required autoComplete="off" /> + + + + ); +} diff --git a/src/input/Toggle.module.css b/src/input/Toggle.module.css new file mode 100644 index 00000000..929dd424 --- /dev/null +++ b/src/input/Toggle.module.css @@ -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; +} diff --git a/src/matrix-utils.js b/src/matrix-utils.js index 04ce3dfd..bdca07d8 100644 --- a/src/matrix-utils.js +++ b/src/matrix-utils.js @@ -76,7 +76,7 @@ export function isLocalRoomId(roomId) { 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({ visibility: "private", preset: "public_chat", @@ -107,9 +107,12 @@ export async function createRoom(client, name) { }, }); + console.log({ isPtt }); + await client.createGroupCall( room_id, - GroupCallType.Video, + isPtt ? GroupCallType.Voice : GroupCallType.Video, + isPtt, GroupCallIntent.Prompt ); diff --git a/src/room/AudioPreview.jsx b/src/room/AudioPreview.jsx new file mode 100644 index 00000000..7cfd612d --- /dev/null +++ b/src/room/AudioPreview.jsx @@ -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 ( + <> +

{`${roomName} - Radio Call`}

+
+ {state === GroupCallState.LocalCallFeedUninitialized && ( + + Microphone permissions needed to join the call. + + )} + {state === GroupCallState.InitializingLocalCallFeed && ( + + Accept microphone permissions to join the call. + + )} + {state === GroupCallState.LocalCallFeedInitialized && ( + <> + + {audioInputs.map(({ deviceId, label }) => ( + {label} + ))} + + {audioOutputs.length > 0 && ( + + {audioOutputs.map(({ deviceId, label }) => ( + {label} + ))} + + )} + + )} +
+ + ); +} diff --git a/src/room/AudioPreview.module.css b/src/room/AudioPreview.module.css new file mode 100644 index 00000000..3ed24d8f --- /dev/null +++ b/src/room/AudioPreview.module.css @@ -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; + } +} diff --git a/src/room/GroupCallView.jsx b/src/room/GroupCallView.jsx index 908613fb..b8052d6b 100644 --- a/src/room/GroupCallView.jsx +++ b/src/room/GroupCallView.jsx @@ -5,6 +5,7 @@ import { useGroupCall } from "./useGroupCall"; import { ErrorView, FullScreenView } from "../FullScreenView"; import { LobbyView } from "./LobbyView"; import { InCallView } from "./InCallView"; +import { PTTCallView } from "./PTTCallView"; import { CallEndedView } from "./CallEndedView"; import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler"; import { useLocationNavigation } from "../useLocationNavigation"; @@ -47,6 +48,7 @@ export function GroupCallView({ localScreenshareFeed, screenshareFeeds, hasLocalParticipant, + participants, } = useGroupCall(groupCall); useEffect(() => { @@ -72,27 +74,43 @@ export function GroupCallView({ if (error) { return ; } else if (state === GroupCallState.Entered) { - return ( - - ); + if (groupCall.isPtt) { + return ( + + ); + } else { + return ( + + ); + } } else if (state === GroupCallState.Entering) { return ( @@ -105,6 +123,7 @@ export function GroupCallView({ return ( { onInitLocalCallFeed(); @@ -64,53 +64,31 @@ export function LobbyView({
-
-
+ {groupCall.isPtt ? ( + + ) : ( + + )} + ); +} diff --git a/src/room/PTTButton.module.css b/src/room/PTTButton.module.css new file mode 100644 index 00000000..1a8dd179 --- /dev/null +++ b/src/room/PTTButton.module.css @@ -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); +} diff --git a/src/room/PTTCallView.jsx b/src/room/PTTCallView.jsx new file mode 100644 index 00000000..6993ec9e --- /dev/null +++ b/src/room/PTTCallView.jsx @@ -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 ( +
+
+ + + + +
+
+
+

{`${participants.length} ${ + participants.length > 1 ? "people" : "person" + } connected`}

+ +
+
+ settingsModalState.open()} /> + + inviteModalState.open()} /> +
+ +
+ {activeSpeakerUserId ? ( +
+

+ {!activeSpeakerIsLocalUser && ( + + )} + {activeSpeakerIsLocalUser + ? "Talking..." + : `${activeSpeakerDisplayName} is talking...`} +

+ +
+ ) : ( +
+ )} + +

+ {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"} +

+ {userMediaFeeds.map((callFeed) => ( + + ))} + {isAdmin && ( + + )} +
+
+ + {settingsModalState.isOpen && ( + + )} + {inviteModalState.isOpen && ( + + )} +
+ ); +} diff --git a/src/room/PTTCallView.module.css b/src/room/PTTCallView.module.css new file mode 100644 index 00000000..54665e4f --- /dev/null +++ b/src/room/PTTCallView.module.css @@ -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; + } +} diff --git a/src/room/PTTFeed.jsx b/src/room/PTTFeed.jsx new file mode 100644 index 00000000..cc655c76 --- /dev/null +++ b/src/room/PTTFeed.jsx @@ -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