Finish basic ptt implemenation

This commit is contained in:
Robert Long 2022-04-28 17:44:50 -07:00
parent 3a6346aa63
commit 363f2340a0
9 changed files with 350 additions and 45 deletions

36
src/input/Toggle.jsx Normal file
View file

@ -0,0 +1,36 @@
import React, { useRef } from "react";
import styles from "./Toggle.module.css";
import { useToggleButton } from "@react-aria/button";
import { useToggleState } from "@react-stately/toggle";
import classNames from "classnames";
import { Field } from "./Input";
export function Toggle({ id, label, className, ...rest }) {
const buttonRef = useRef();
const state = useToggleState(rest);
const { buttonProps, isPressed } = useToggleButton(rest, state, buttonRef);
return (
<Field
className={classNames(
styles.toggle,
{ [styles.on]: isPressed },
className
)}
>
<button
{...buttonProps}
ref={buttonRef}
id={id}
className={classNames(styles.button, {
[styles.isPressed]: isPressed,
})}
>
<div className={styles.ball} />
</button>
<label className={styles.label} htmlFor={id}>
{label}
</label>
</Field>
);
}

View file

@ -0,0 +1,46 @@
.toggle {
align-items: center;
margin-bottom: 20px;
}
.button {
position: relative;
padding: 0;
transition: background-color 0.20s 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;
}

View file

@ -77,18 +77,15 @@ export function GroupCallView({
if (groupCall.isPtt) {
return (
<PTTCallView
client={client}
roomId={roomId}
roomName={groupCall.room.name}
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 {

View file

@ -1,41 +1,41 @@
import classNames from "classnames";
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({ client, activeSpeaker }) {
const size = 232;
const isLocal = client.userId === activeSpeaker;
const avatarUrl = activeSpeaker?.user?.avatarUrl;
export function PTTButton({
showTalkOverError,
activeSpeakerUserId,
activeSpeakerDisplayName,
activeSpeakerAvatarUrl,
activeSpeakerIsLocalUser,
size,
}) {
return (
<button
className={classNames(styles.pttButton, {
[styles.speaking]: !!activeSpeaker,
[styles.talking]: activeSpeakerUserId,
[styles.error]: showTalkOverError,
})}
>
{isLocal || !avatarUrl || !activeSpeaker ? (
{activeSpeakerIsLocalUser || !activeSpeakerUserId ? (
<MicIcon
classNames={styles.micIcon}
className={styles.micIcon}
width={size / 3}
height={size / 3}
/>
) : (
<Avatar
key={activeSpeaker.userId}
key={activeSpeakerUserId}
style={{
width: size,
height: size,
borderRadius: size,
fontSize: Math.round(size / 2),
width: size - 12,
height: size - 12,
borderRadius: size - 12,
fontSize: Math.round((size - 12) / 2),
}}
src={
activeSpeaker.user.avatarUrl &&
getAvatarUrl(client, activeSpeaker.user.avatarUrl, size)
}
fallback={activeSpeaker.name.slice(0, 1).toUpperCase()}
src={activeSpeakerAvatarUrl}
fallback={activeSpeakerDisplayName.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
)}

View file

@ -4,8 +4,20 @@
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);
}

View file

@ -2,10 +2,8 @@ import React from "react";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
import { Button, HangupButton, InviteButton, SettingsButton } from "../button";
import { HangupButton, InviteButton, SettingsButton } 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";
@ -13,28 +11,59 @@ 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,
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();
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,
} = 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}>
@ -64,16 +93,40 @@ export function PTTCallView({
</div>
<div className={styles.pttButtonContainer}>
<div className={styles.talkingInfo}>
<h2>Talking...</h2>
<p>00:01:24</p>
</div>
{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
client={client}
activeSpeaker={activeSpeaker}
groupCall={groupCall}
showTalkOverError={showTalkOverError}
activeSpeakerUserId={activeSpeakerUserId}
activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
size={pttButtonSize}
/>
<p className={styles.actionTip}>Press and hold spacebar to talk</p>
<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}
@ -81,6 +134,14 @@ export function PTTCallView({
audioOutputDevice={audioOutput}
/>
))}
{isAdmin && (
<Toggle
isSelected={talkOverEnabled}
onChange={setTalkOverEnabled}
label="Talk over speaker"
id="talkOverEnabled"
/>
)}
</div>
</div>

View file

@ -38,6 +38,11 @@
flex-direction: column;
align-items: center;
margin-bottom: 20px;
height: 88px;
}
.speakerIcon {
margin-right: 8px;
}
.pttButtonContainer {

37
src/room/Timer.jsx Normal file
View 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>;
}

111
src/room/usePTT.js Normal file
View file

@ -0,0 +1,111 @@
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]);
useEffect(() => {
function onKeyDown(event) {
if (event.code === "Space") {
event.preventDefault();
if (!activeSpeakerUserId || isAdmin || talkOverEnabled) {
if (groupCall.isMicrophoneMuted()) {
groupCall.setMicrophoneMuted(false);
}
setState((prevState) => ({ ...prevState, pttButtonHeld: true }));
}
}
}
function onKeyUp(event) {
if (event.code === "Space") {
event.preventDefault();
if (!groupCall.isMicrophoneMuted()) {
groupCall.setMicrophoneMuted(true);
}
setState((prevState) => ({ ...prevState, pttButtonHeld: false }));
}
}
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,
};
}