From 38f9a79bd3d79fad83822b0e49314f00c0270fa0 Mon Sep 17 00:00:00 2001 From: Robert Long <robert@robertlong.me> Date: Fri, 22 Apr 2022 18:05:48 -0700 Subject: [PATCH] Initial PTT designs --- src/button/Button.jsx | 1 + src/button/Button.module.css | 9 +++ src/home/RegisteredView.jsx | 12 +++- src/home/RegisteredView.module.css | 4 ++ src/home/UnauthenticatedView.jsx | 11 +++- src/matrix-utils.js | 5 +- src/room/GroupCallView.jsx | 63 ++++++++++++++------- src/room/PTTButton.jsx | 44 +++++++++++++++ src/room/PTTButton.module.css | 11 ++++ src/room/PTTCallView.jsx | 88 ++++++++++++++++++++++++++++++ src/room/PTTCallView.module.css | 40 ++++++++++++++ src/room/PTTFeed.jsx | 10 ++++ src/room/PTTFeed.module.css | 3 + 13 files changed, 277 insertions(+), 24 deletions(-) create mode 100644 src/room/PTTButton.jsx create mode 100644 src/room/PTTButton.module.css create mode 100644 src/room/PTTCallView.jsx create mode 100644 src/room/PTTCallView.module.css create mode 100644 src/room/PTTFeed.jsx create mode 100644 src/room/PTTFeed.module.css diff --git a/src/button/Button.jsx b/src/button/Button.jsx index b6c3df9..ab6ed77 100644 --- a/src/button/Button.jsx +++ b/src/button/Button.jsx @@ -20,6 +20,7 @@ export const variantToClassName = { copy: [styles.copyButton], iconCopy: [styles.iconCopyButton], secondaryCopy: [styles.copyButton], + secondaryHangup: [styles.secondaryHangup], }; export const sizeToClassName = { diff --git a/src/button/Button.module.css b/src/button/Button.module.css index 72450d5..5c3ec30 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 6e43f20..6849d9f 100644 --- a/src/home/RegisteredView.jsx +++ b/src/home/RegisteredView.jsx @@ -23,12 +23,13 @@ export function RegisteredView({ client }) { 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 +88,7 @@ export function RegisteredView({ client }) { required autoComplete="off" /> + <Button type="submit" size="lg" @@ -96,6 +98,14 @@ export function RegisteredView({ client }) { {loading ? "Loading..." : "Go"} </Button> </FieldRow> + <FieldRow className={styles.fieldRow}> + <InputField + id="ptt" + name="ptt" + label="Push to Talk" + type="checkbox" + /> + </FieldRow> {error && ( <FieldRow className={styles.fieldRow}> <ErrorMessage>{error.message}</ErrorMessage> diff --git a/src/home/RegisteredView.module.css b/src/home/RegisteredView.module.css index 3783d5e..03f5b21 100644 --- a/src/home/RegisteredView.module.css +++ b/src/home/RegisteredView.module.css @@ -7,6 +7,10 @@ } .fieldRow { + margin-bottom: 24px;; +} + +.fieldRow:last-child { margin-bottom: 0; } diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx index 21db6ac..b39bfc7 100644 --- a/src/home/UnauthenticatedView.jsx +++ b/src/home/UnauthenticatedView.jsx @@ -28,6 +28,7 @@ export function UnauthenticatedView() { const data = new FormData(e.target); const roomName = data.get("callName"); const displayName = data.get("displayName"); + const ptt = data.get("ptt") !== null; async function submit() { setError(undefined); @@ -41,7 +42,7 @@ export function UnauthenticatedView() { recaptchaResponse, true ); - const roomIdOrAlias = await createRoom(client, roomName); + const roomIdOrAlias = await createRoom(client, roomName, ptt); if (roomIdOrAlias) { history.push(`/room/${roomIdOrAlias}`); @@ -111,6 +112,14 @@ export function UnauthenticatedView() { autoComplete="off" /> </FieldRow> + <FieldRow> + <InputField + id="ptt" + name="ptt" + label="Push to Talk" + type="checkbox" + /> + </FieldRow> <Caption> By clicking "Go", you agree to our{" "} <Link href={privacyPolicyUrl}>Terms and conditions</Link> diff --git a/src/matrix-utils.js b/src/matrix-utils.js index 04ce3df..bef1b80 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, GroupCallIntent.Prompt ); diff --git a/src/room/GroupCallView.jsx b/src/room/GroupCallView.jsx index 908613f..3f21a3b 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,46 @@ export function GroupCallView({ if (error) { return <ErrorView error={error} />; } else if (state === GroupCallState.Entered) { - return ( - <InCallView - groupCall={groupCall} - 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} - /> - ); + if (groupCall.isPtt) { + return ( + <PTTCallView + groupCall={groupCall} + participants={participants} + client={client} + roomName={groupCall.room.name} + microphoneMuted={microphoneMuted} + toggleMicrophoneMuted={toggleMicrophoneMuted} + userMediaFeeds={userMediaFeeds} + activeSpeaker={activeSpeaker} + onLeave={onLeave} + setShowInspector={onChangeShowInspector} + showInspector={showInspector} + roomId={roomId} + /> + ); + } else { + return ( + <InCallView + groupCall={groupCall} + 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) { return ( <FullScreenView> diff --git a/src/room/PTTButton.jsx b/src/room/PTTButton.jsx new file mode 100644 index 0000000..d943ae4 --- /dev/null +++ b/src/room/PTTButton.jsx @@ -0,0 +1,44 @@ +import classNames from "classnames"; +import React from "react"; +import styles from "./PTTButton.module.css"; +import { ReactComponent as MicIcon } from "../icons/Mic.svg"; +import { Avatar } from "../Avatar"; + +export function PTTButton({ client, activeSpeaker }) { + const size = 232; + + const isLocal = client.userId === activeSpeaker; + const avatarUrl = activeSpeaker?.user?.avatarUrl; + + return ( + <button + className={classNames(styles.pttButton, { + [styles.speaking]: !!activeSpeaker, + })} + > + {isLocal || !avatarUrl || !activeSpeaker ? ( + <MicIcon + classNames={styles.micIcon} + width={size / 3} + height={size / 3} + /> + ) : ( + <Avatar + key={activeSpeaker.userId} + style={{ + width: size, + height: size, + borderRadius: size, + fontSize: Math.round(size / 2), + }} + src={ + activeSpeaker.user.avatarUrl && + getAvatarUrl(client, activeSpeaker.user.avatarUrl, size) + } + fallback={activeSpeaker.name.slice(0, 1).toUpperCase()} + className={styles.avatar} + /> + )} + </button> + ); +} diff --git a/src/room/PTTButton.module.css b/src/room/PTTButton.module.css new file mode 100644 index 0000000..6bfc1a1 --- /dev/null +++ b/src/room/PTTButton.module.css @@ -0,0 +1,11 @@ +.pttButton { + width: 100vw; + height: 100vh; + max-height: 232px; + max-width: 232px; + border-radius: 116px; + + color: ##fff; + border: 6px solid #0dbd8b; + background-color: #21262C; +} \ No newline at end of file diff --git a/src/room/PTTCallView.jsx b/src/room/PTTCallView.jsx new file mode 100644 index 0000000..29d90da --- /dev/null +++ b/src/room/PTTCallView.jsx @@ -0,0 +1,88 @@ +import React from "react"; +import { useModalTriggerState } from "../Modal"; +import { SettingsModal } from "../settings/SettingsModal"; +import { InviteModal } from "./InviteModal"; +import { Button } from "../button"; +import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header"; +import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg"; +import { ReactComponent as SettingsIcon } from "../icons/Settings.svg"; +import styles from "./PTTCallView.module.css"; +import { Facepile } from "../Facepile"; +import { PTTButton } from "./PTTButton"; +import { PTTFeed } from "./PTTFeed"; +import { useMediaHandler } from "../settings/useMediaHandler"; + +export function PTTCallView({ + groupCall, + participants, + client, + roomName, + microphoneMuted, + toggleMicrophoneMuted, + userMediaFeeds, + activeSpeaker, + onLeave, + setShowInspector, + showInspector, + roomId, +}) { + const { modalState: inviteModalState, modalProps: inviteModalProps } = + useModalTriggerState(); + const { modalState: settingsModalState, modalProps: settingsModalProps } = + useModalTriggerState(); + const { audioOutput } = useMediaHandler(); + + return ( + <div className={styles.pttCallView}> + <Header className={styles.header}> + <LeftNav> + <RoomSetupHeaderInfo roomName={roomName} onPress={onLeave} /> + </LeftNav> + <RightNav> + <Button variant="secondaryHangup" onPress={onLeave}> + Leave + </Button> + <Button variant="icon" onPress={() => inviteModalState.open()}> + <AddUserIcon /> + </Button> + <Button variant="icon" onPress={() => settingsModalState.open()}> + <SettingsIcon /> + </Button> + </RightNav> + </Header> + <div className={styles.headerSeparator} /> + <div className={styles.participants}> + <p>{`${participants.length} user${ + participants.length > 1 ? "s" : "" + } connected`}</p> + <Facepile client={client} participants={participants} /> + </div> + <div className={styles.center}> + <PTTButton + client={client} + activeSpeaker={activeSpeaker} + groupCall={groupCall} + /> + <p className={styles.actionTip}>Press and hold spacebar to talk</p> + {userMediaFeeds.map((callFeed) => ( + <PTTFeed + key={callFeed.userId} + callFeed={callFeed} + audioOutputDevice={audioOutput} + /> + ))} + </div> + + {settingsModalState.isOpen && ( + <SettingsModal + {...settingsModalProps} + setShowInspector={setShowInspector} + showInspector={showInspector} + /> + )} + {inviteModalState.isOpen && ( + <InviteModal roomId={roomId} {...inviteModalProps} /> + )} + </div> + ); +} diff --git a/src/room/PTTCallView.module.css b/src/room/PTTCallView.module.css new file mode 100644 index 0000000..f513b79 --- /dev/null +++ b/src/room/PTTCallView.module.css @@ -0,0 +1,40 @@ +.pttCallView { + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 100%; + position: fixed; + height: 100%; + width: 100%; +} + +.headerSeparator { + width: calc(100% - 40px); + height: 1px; + margin: 0 20px; + background-color: #21262C; +} + +.center { + width: 100%; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.actionTip { + margin-top: 42px; + font-size: 17px; +} + +.participants { + margin: 24px 20px 0 20px; +} + +.participants > p { + color: #A9B2BC; + margin-bottom: 8px; +} \ No newline at end of file diff --git a/src/room/PTTFeed.jsx b/src/room/PTTFeed.jsx new file mode 100644 index 0000000..cc655c7 --- /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 <audio ref={mediaRef} className={styles.audioFeed} playsInline />; +} diff --git a/src/room/PTTFeed.module.css b/src/room/PTTFeed.module.css new file mode 100644 index 0000000..d7237c3 --- /dev/null +++ b/src/room/PTTFeed.module.css @@ -0,0 +1,3 @@ +.audioFeed { + display: none; +} \ No newline at end of file