Finish basic ptt implemenation
This commit is contained in:
parent
3a6346aa63
commit
363f2340a0
9 changed files with 350 additions and 45 deletions
36
src/input/Toggle.jsx
Normal file
36
src/input/Toggle.jsx
Normal 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>
|
||||
);
|
||||
}
|
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.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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
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>;
|
||||
}
|
111
src/room/usePTT.js
Normal file
111
src/room/usePTT.js
Normal 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,
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue