Merge branch 'main' into chrome-spatial-aec

This commit is contained in:
Robin Townsend 2022-06-16 09:56:27 -04:00
commit dcae5ad5f2
10 changed files with 221 additions and 144 deletions

1
.env
View file

@ -22,6 +22,7 @@
# VITE_THEME_PRIMARY_CONTENT=#ffffff # VITE_THEME_PRIMARY_CONTENT=#ffffff
# VITE_THEME_SECONDARY_CONTENT=#a9b2bc # VITE_THEME_SECONDARY_CONTENT=#a9b2bc
# VITE_THEME_TERTIARY_CONTENT=#8e99a4 # VITE_THEME_TERTIARY_CONTENT=#8e99a4
# VITE_THEME_TERTIARY_CONTENT_20=#8e99a433
# VITE_THEME_QUATERNARY_CONTENT=#6f7882 # VITE_THEME_QUATERNARY_CONTENT=#6f7882
# VITE_THEME_QUINARY_CONTENT=#394049 # VITE_THEME_QUINARY_CONTENT=#394049
# VITE_THEME_SYSTEM=#21262c # VITE_THEME_SYSTEM=#21262c

View file

@ -33,6 +33,7 @@ limitations under the License.
--primary-content: #ffffff; --primary-content: #ffffff;
--secondary-content: #a9b2bc; --secondary-content: #a9b2bc;
--tertiary-content: #8e99a4; --tertiary-content: #8e99a4;
--tertiary-content-20: #8e99a433;
--quaternary-content: #6f7882; --quaternary-content: #6f7882;
--quinary-content: #394049; --quinary-content: #394049;
--system: #21262c; --system: #21262c;

View file

@ -61,6 +61,10 @@ if (import.meta.env.VITE_CUSTOM_THEME) {
"--tertiary-content", "--tertiary-content",
import.meta.env.VITE_THEME_TERTIARY_CONTENT as string import.meta.env.VITE_THEME_TERTIARY_CONTENT as string
); );
style.setProperty(
"--tertiary-content-20",
import.meta.env.VITE_THEME_TERTIARY_CONTENT_20 as string
);
style.setProperty( style.setProperty(
"--quaternary-content", "--quaternary-content",
import.meta.env.VITE_THEME_QUATERNARY_CONTENT as string import.meta.env.VITE_THEME_QUATERNARY_CONTENT as string

View file

@ -17,6 +17,12 @@
cursor: unset; cursor: unset;
} }
.networkWaiting {
background-color: var(--tertiary-content);
border-color: var(--tertiary-content);
cursor: unset;
}
.error { .error {
background-color: var(--alert); background-color: var(--alert);
border-color: var(--alert); border-color: var(--alert);

View file

@ -14,15 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useEffect, useState, createRef } from "react"; import React, { useCallback, useState, createRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { useSpring, animated } from "@react-spring/web"; import { useSpring, animated } from "@react-spring/web";
import styles from "./PTTButton.module.css"; import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg"; import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { useEventTarget } from "../useEvents";
import { Avatar } from "../Avatar"; import { Avatar } from "../Avatar";
interface Props { interface Props {
enabled: boolean;
showTalkOverError: boolean; showTalkOverError: boolean;
activeSpeakerUserId: string; activeSpeakerUserId: string;
activeSpeakerDisplayName: string; activeSpeakerDisplayName: string;
@ -32,15 +34,13 @@ interface Props {
size: number; size: number;
startTalking: () => void; startTalking: () => void;
stopTalking: () => void; stopTalking: () => void;
} networkWaiting: boolean;
enqueueNetworkWaiting: (value: boolean, delay: number) => void;
interface State { setNetworkWaiting: (value: boolean) => void;
isHeld: boolean;
// If the button is being pressed by touch, the ID of that touch
activeTouchID: number | null;
} }
export const PTTButton: React.FC<Props> = ({ export const PTTButton: React.FC<Props> = ({
enabled,
showTalkOverError, showTalkOverError,
activeSpeakerUserId, activeSpeakerUserId,
activeSpeakerDisplayName, activeSpeakerDisplayName,
@ -50,89 +50,110 @@ export const PTTButton: React.FC<Props> = ({
size, size,
startTalking, startTalking,
stopTalking, stopTalking,
networkWaiting,
enqueueNetworkWaiting,
setNetworkWaiting,
}) => { }) => {
const buttonRef = createRef<HTMLButtonElement>(); const buttonRef = createRef<HTMLButtonElement>();
const [{ isHeld, activeTouchID }, setState] = useState<State>({ const [activeTouchId, setActiveTouchId] = useState<number | null>(null);
isHeld: false,
activeTouchID: null,
});
const onWindowMouseUp = useCallback(
(e) => {
if (isHeld) stopTalking();
setState({ isHeld: false, activeTouchID: null });
},
[isHeld, setState, stopTalking]
);
const onWindowTouchEnd = useCallback( const hold = useCallback(() => {
(e: TouchEvent) => { // This update is delayed so the user only sees it if latency is significant
// ignore any ended touches that weren't the one pressing the enqueueNetworkWaiting(true, 100);
// button (bafflingly the TouchList isn't an iterable so we startTalking();
// have to do this a really old-school way). }, [enqueueNetworkWaiting, startTalking]);
let touchFound = false; const unhold = useCallback(() => {
for (let i = 0; i < e.changedTouches.length; ++i) { setNetworkWaiting(false);
if (e.changedTouches.item(i).identifier === activeTouchID) { stopTalking();
touchFound = true; }, [setNetworkWaiting, stopTalking]);
break;
}
}
if (!touchFound) return;
e.preventDefault();
if (isHeld) stopTalking();
setState({ isHeld: false, activeTouchID: null });
},
[isHeld, activeTouchID, setState, stopTalking]
);
const onButtonMouseDown = useCallback( const onButtonMouseDown = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => { (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
setState({ isHeld: true, activeTouchID: null }); hold();
startTalking();
}, },
[setState, startTalking] [hold]
); );
const onButtonTouchStart = useCallback( // These listeners go on the window so even if the user's cursor / finger
(e: TouchEvent) => { // leaves the button while holding it, the button stays pushed until
e.preventDefault(); // they stop clicking / tapping.
useEventTarget(window, "mouseup", unhold);
useEventTarget(
window,
"touchend",
useCallback(
(e: TouchEvent) => {
// ignore any ended touches that weren't the one pressing the
// button (bafflingly the TouchList isn't an iterable so we
// have to do this a really old-school way).
let touchFound = false;
for (let i = 0; i < e.changedTouches.length; ++i) {
if (e.changedTouches.item(i).identifier === activeTouchId) {
touchFound = true;
break;
}
}
if (!touchFound) return;
if (isHeld) return; e.preventDefault();
unhold();
setState({ setActiveTouchId(null);
isHeld: true, },
activeTouchID: e.changedTouches.item(0).identifier, [unhold, activeTouchId, setActiveTouchId]
}); )
startTalking();
},
[isHeld, setState, startTalking]
); );
useEffect(() => { // This is a native DOM listener too because we want to preventDefault in it
const currentButtonElement = buttonRef.current; // to stop also getting a click event, so we need it to be non-passive.
useEventTarget(
buttonRef.current,
"touchstart",
useCallback(
(e: TouchEvent) => {
e.preventDefault();
// These listeners go on the window so even if the user's cursor / finger hold();
// leaves the button while holding it, the button stays pushed until setActiveTouchId(e.changedTouches.item(0).identifier);
// they stop clicking / tapping. },
window.addEventListener("mouseup", onWindowMouseUp); [hold, setActiveTouchId]
window.addEventListener("touchend", onWindowTouchEnd); ),
// This is a native DOM listener too because we want to preventDefault in it { passive: false }
// to stop also getting a click event, so we need it to be non-passive. );
currentButtonElement.addEventListener("touchstart", onButtonTouchStart, {
passive: false,
});
return () => { useEventTarget(
window.removeEventListener("mouseup", onWindowMouseUp); window,
window.removeEventListener("touchend", onWindowTouchEnd); "keydown",
currentButtonElement.removeEventListener( useCallback(
"touchstart", (e: KeyboardEvent) => {
onButtonTouchStart if (e.code === "Space") {
); if (!enabled) return;
}; e.preventDefault();
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
hold();
}
},
[enabled, hold]
)
);
useEventTarget(
window,
"keyup",
useCallback(
(e: KeyboardEvent) => {
if (e.code === "Space") {
e.preventDefault();
unhold();
}
},
[unhold]
)
);
// TODO: We will need to disable this for a global PTT hotkey to work
useEventTarget(window, "blur", unhold);
const { shadow } = useSpring({ const { shadow } = useSpring({
shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6, shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6,
@ -143,12 +164,15 @@ export const PTTButton: React.FC<Props> = ({
}); });
const shadowColor = showTalkOverError const shadowColor = showTalkOverError
? "var(--alert-20)" ? "var(--alert-20)"
: networkWaiting
? "var(--tertiary-content-20)"
: "var(--accent-20)"; : "var(--accent-20)";
return ( return (
<animated.button <animated.button
className={classNames(styles.pttButton, { className={classNames(styles.pttButton, {
[styles.talking]: activeSpeakerUserId, [styles.talking]: activeSpeakerUserId,
[styles.networkWaiting]: networkWaiting,
[styles.error]: showTalkOverError, [styles.error]: showTalkOverError,
})} })}
style={{ style={{

View file

@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { useEffect } from "react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer"; import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk"; import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useDelayedState } from "../useDelayedState";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { InviteModal } from "./InviteModal"; import { InviteModal } from "./InviteModal";
import { HangupButton, InviteButton } from "../button"; import { HangupButton, InviteButton } from "../button";
@ -39,6 +40,7 @@ import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu"; import { OverflowMenu } from "./OverflowMenu";
function getPromptText( function getPromptText(
networkWaiting: boolean,
showTalkOverError: boolean, showTalkOverError: boolean,
pttButtonHeld: boolean, pttButtonHeld: boolean,
activeSpeakerIsLocalUser: boolean, activeSpeakerIsLocalUser: boolean,
@ -47,10 +49,14 @@ function getPromptText(
activeSpeakerDisplayName: string, activeSpeakerDisplayName: string,
connected: boolean connected: boolean
): string { ): string {
if (!connected) return "Connection Lost"; if (!connected) return "Connection lost";
const isTouchScreen = Boolean(window.ontouchstart !== undefined); const isTouchScreen = Boolean(window.ontouchstart !== undefined);
if (networkWaiting) {
return "Waiting for network";
}
if (showTalkOverError) { if (showTalkOverError) {
return "You can't talk at the same time"; return "You can't talk at the same time";
} }
@ -128,18 +134,15 @@ export const PTTCallView: React.FC<Props> = ({
stopTalking, stopTalking,
transmitBlocked, transmitBlocked,
connected, connected,
} = usePTT( } = usePTT(client, groupCall, userMediaFeeds, playClip);
client,
groupCall,
userMediaFeeds,
playClip,
!feedbackModalState.isOpen
);
const [talkingExpected, enqueueTalkingExpected, setTalkingExpected] =
useDelayedState(false);
const showTalkOverError = pttButtonHeld && transmitBlocked; const showTalkOverError = pttButtonHeld && transmitBlocked;
const networkWaiting =
talkingExpected && !activeSpeakerUserId && !showTalkOverError;
const activeSpeakerIsLocalUser = const activeSpeakerIsLocalUser = activeSpeakerUserId === client.getUserId();
activeSpeakerUserId && client.getUserId() === activeSpeakerUserId;
const activeSpeakerUser = activeSpeakerUserId const activeSpeakerUser = activeSpeakerUserId
? client.getUser(activeSpeakerUserId) ? client.getUser(activeSpeakerUserId)
: null; : null;
@ -148,6 +151,10 @@ export const PTTCallView: React.FC<Props> = ({
? activeSpeakerUser.displayName ? activeSpeakerUser.displayName
: ""; : "";
useEffect(() => {
setTalkingExpected(activeSpeakerIsLocalUser);
}, [activeSpeakerIsLocalUser, setTalkingExpected]);
return ( return (
<div className={styles.pttCallView} ref={containerRef}> <div className={styles.pttCallView} ref={containerRef}>
<PTTClips <PTTClips
@ -217,6 +224,7 @@ export const PTTCallView: React.FC<Props> = ({
<div className={styles.talkingInfo} /> <div className={styles.talkingInfo} />
)} )}
<PTTButton <PTTButton
enabled={!feedbackModalState.isOpen}
showTalkOverError={showTalkOverError} showTalkOverError={showTalkOverError}
activeSpeakerUserId={activeSpeakerUserId} activeSpeakerUserId={activeSpeakerUserId}
activeSpeakerDisplayName={activeSpeakerDisplayName} activeSpeakerDisplayName={activeSpeakerDisplayName}
@ -226,9 +234,13 @@ export const PTTCallView: React.FC<Props> = ({
size={pttButtonSize} size={pttButtonSize}
startTalking={startTalking} startTalking={startTalking}
stopTalking={stopTalking} stopTalking={stopTalking}
networkWaiting={networkWaiting}
enqueueNetworkWaiting={enqueueTalkingExpected}
setNetworkWaiting={setTalkingExpected}
/> />
<p className={styles.actionTip}> <p className={styles.actionTip}>
{getPromptText( {getPromptText(
networkWaiting,
showTalkOverError, showTalkOverError,
pttButtonHeld, pttButtonHeld,
activeSpeakerIsLocalUser, activeSpeakerIsLocalUser,

View file

@ -80,8 +80,7 @@ export const usePTT = (
client: MatrixClient, client: MatrixClient,
groupCall: GroupCall, groupCall: GroupCall,
userMediaFeeds: CallFeed[], userMediaFeeds: CallFeed[],
playClip: PlayClipFunction, playClip: PlayClipFunction
enablePTTButton: boolean
): PTTState => { ): PTTState => {
// Used to serialise all the mute calls so they don't race. It has // Used to serialise all the mute calls so they don't race. It has
// its own state as its always set separately from anything else. // its own state as its always set separately from anything else.
@ -258,59 +257,6 @@ export const usePTT = (
[setConnected] [setConnected]
); );
useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
if (event.code === "Space") {
if (!enablePTTButton) return;
event.preventDefault();
if (pttButtonHeld) return;
startTalking();
}
}
function onKeyUp(event: KeyboardEvent): void {
if (event.code === "Space") {
event.preventDefault();
stopTalking();
}
}
function onBlur(): void {
// TODO: We will need to disable this for a global PTT hotkey to work
if (!groupCall.isMicrophoneMuted()) {
setMicMuteWrapper(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);
};
}, [
groupCall,
startTalking,
stopTalking,
activeSpeakerUserId,
isAdmin,
talkOverEnabled,
pttButtonHeld,
enablePTTButton,
setMicMuteWrapper,
client,
onClientSync,
]);
useEffect(() => { useEffect(() => {
client.on(ClientEvent.Sync, onClientSync); client.on(ClientEvent.Sync, onClientSync);

View file

@ -42,7 +42,7 @@ export const PTTClips: React.FC<Props> = ({
return ( return (
<> <>
<audio <audio
preload="true" preload="auto"
className={styles.pttClip} className={styles.pttClip}
ref={startTalkingLocalRef} ref={startTalkingLocalRef}
> >
@ -50,18 +50,18 @@ export const PTTClips: React.FC<Props> = ({
<source type="audio/mpeg" src={startTalkLocalMp3Url} /> <source type="audio/mpeg" src={startTalkLocalMp3Url} />
</audio> </audio>
<audio <audio
preload="true" preload="auto"
className={styles.pttClip} className={styles.pttClip}
ref={startTalkingRemoteRef} ref={startTalkingRemoteRef}
> >
<source type="audio/ogg" src={startTalkRemoteOggUrl} /> <source type="audio/ogg" src={startTalkRemoteOggUrl} />
<source type="audio/mpeg" src={startTalkRemoteMp3Url} /> <source type="audio/mpeg" src={startTalkRemoteMp3Url} />
</audio> </audio>
<audio preload="true" className={styles.pttClip} ref={endTalkingRef}> <audio preload="auto" className={styles.pttClip} ref={endTalkingRef}>
<source type="audio/ogg" src={endTalkOggUrl} /> <source type="audio/ogg" src={endTalkOggUrl} />
<source type="audio/mpeg" src={endTalkMp3Url} /> <source type="audio/mpeg" src={endTalkMp3Url} />
</audio> </audio>
<audio preload="true" className={styles.pttClip} ref={blockedRef}> <audio preload="auto" className={styles.pttClip} ref={blockedRef}>
<source type="audio/ogg" src={blockedOggUrl} /> <source type="audio/ogg" src={blockedOggUrl} />
<source type="audio/mpeg" src={blockedMp3Url} /> <source type="audio/mpeg" src={blockedMp3Url} />
</audio> </audio>

49
src/useDelayedState.ts Normal file
View file

@ -0,0 +1,49 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useRef, useCallback } from "react";
// Like useState, except state updates can be enqueued with a configurable delay
export const useDelayedState = <T>(
initial?: T
): [T, (value: T, delay: number) => void, (value: T) => void] => {
const [state, setState] = useState<T>(initial);
const timers = useRef<Set<ReturnType<typeof setTimeout>>>();
if (!timers.current) timers.current = new Set();
const setStateDelayed = useCallback(
(value: T, delay: number) => {
const timer = setTimeout(() => {
setState(value);
timers.current.delete(timer);
}, delay);
timers.current.add(timer);
},
[setState, timers]
);
const setStateImmediate = useCallback(
(value: T) => {
// Clear all updates currently in the queue
for (const timer of timers.current) clearTimeout(timer);
timers.current.clear();
setState(value);
},
[setState, timers]
);
return [state, setStateDelayed, setStateImmediate];
};

34
src/useEvents.ts Normal file
View file

@ -0,0 +1,34 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useEffect } from "react";
// Shortcut for registering a listener on an EventTarget
export const useEventTarget = <T extends Event>(
target: EventTarget,
eventType: string,
listener: (event: T) => void,
options?: AddEventListenerOptions
) => {
useEffect(() => {
if (target) {
target.addEventListener(eventType, listener, options);
return () => target.removeEventListener(eventType, listener, options);
}
}, [target, eventType, listener, options]);
};
// TODO: Have a similar hook for EventEmitters