diff --git a/.eslintrc.js b/.eslintrc.js index 89b5844..1652f6d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,6 +19,7 @@ module.exports = { // We break this rule in a few places: dial it back to a warning // (and run with max warnings) to tolerate the existing code "react-hooks/exhaustive-deps": ["warn"], + "jsx-a11y/media-has-caption": ["off"], }, overrides: [ { diff --git a/src/room/PTTCallView.tsx b/src/room/PTTCallView.tsx index d929713..c068289 100644 --- a/src/room/PTTCallView.tsx +++ b/src/room/PTTCallView.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from "react"; import useMeasure from "react-use-measure"; import { ResizeObserver } from "@juggle/resize-observer"; -import { OtherUserSpeakingError } from "matrix-js-sdk/src/webrtc/groupCall"; import { useModalTriggerState } from "../Modal"; import { SettingsModal } from "../settings/SettingsModal"; @@ -34,6 +33,8 @@ import { Timer } from "./Timer"; import { Toggle } from "../input/Toggle"; import { getAvatarUrl } from "../matrix-utils"; import { ReactComponent as AudioIcon } from "../icons/Audio.svg"; +import { usePTTSounds } from "../sound/usePttSounds"; +import { PTTClips } from "../sound/PTTClips"; export function PTTCallView({ client, @@ -57,6 +58,9 @@ export function PTTCallView({ const { audioOutput } = useMediaHandler(); + const { startTalkingLocalRef, startTalkingRemoteRef, blockedRef, playClip } = + usePTTSounds(); + const { pttButtonHeld, isAdmin, @@ -65,11 +69,10 @@ export function PTTCallView({ activeSpeakerUserId, startTalking, stopTalking, - unmuteError, - } = usePTT(client, groupCall, userMediaFeeds); + transmitBlocked, + } = usePTT(client, groupCall, userMediaFeeds, playClip); - const showTalkOverError = - pttButtonHeld && unmuteError instanceof OtherUserSpeakingError; + const showTalkOverError = pttButtonHeld && transmitBlocked; const activeSpeakerIsLocalUser = activeSpeakerUserId && client.getUserId() === activeSpeakerUserId; @@ -89,6 +92,11 @@ export function PTTCallView({ return (
+
diff --git a/src/room/usePTT.ts b/src/room/usePTT.ts index 24d364c..0d8f106 100644 --- a/src/room/usePTT.ts +++ b/src/room/usePTT.ts @@ -18,6 +18,9 @@ import { useCallback, useEffect, useState } from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { PlayClipFunction, PTTClipID } from "../sound/usePttSounds"; export interface PTTState { pttButtonHeld: boolean; @@ -27,13 +30,14 @@ export interface PTTState { activeSpeakerUserId: string; startTalking: () => void; stopTalking: () => void; - unmuteError: Error; + transmitBlocked: boolean; } export const usePTT = ( client: MatrixClient, groupCall: GroupCall, - userMediaFeeds: CallFeed[] + userMediaFeeds: CallFeed[], + playClip: PlayClipFunction ): PTTState => { const [ { @@ -41,7 +45,7 @@ export const usePTT = ( isAdmin, talkOverEnabled, activeSpeakerUserId, - unmuteError, + transmitBlocked, }, setState, ] = useState(() => { @@ -54,7 +58,7 @@ export const usePTT = ( talkOverEnabled: false, pttButtonHeld: false, activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null, - unmuteError: null, + transmitBlocked: false, }; }); @@ -62,6 +66,21 @@ export const usePTT = ( function onMuteStateChanged(...args): void { const activeSpeakerFeed = userMediaFeeds.find((f) => !f.isAudioMuted()); + if (activeSpeakerUserId === null && activeSpeakerFeed.userId !== null) { + if (activeSpeakerFeed.userId === client.getUserId()) { + playClip(PTTClipID.START_TALKING_LOCAL); + } else { + playClip(PTTClipID.START_TALKING_REMOTE); + } + } else if ( + activeSpeakerFeed && + activeSpeakerUserId === client.getUserId() && + activeSpeakerFeed.userId !== client.getUserId() + ) { + // We were talking but we've been cut off + playClip(PTTClipID.BLOCKED); + } + setState((prevState) => ({ ...prevState, activeSpeakerUserId: activeSpeakerFeed @@ -89,33 +108,55 @@ export const usePTT = ( ); } }; - }, [userMediaFeeds]); + }, [userMediaFeeds, activeSpeakerUserId, client, playClip]); const startTalking = useCallback(async () => { - setState((prevState) => ({ - ...prevState, - pttButtonHeld: true, - unmuteError: null, - })); - if (!activeSpeakerUserId || isAdmin || talkOverEnabled) { + if (pttButtonHeld) return; + + let blocked = false; + if (!activeSpeakerUserId || (isAdmin && talkOverEnabled)) { if (groupCall.isMicrophoneMuted()) { try { await groupCall.setMicrophoneMuted(false); } catch (e) { - setState((prevState) => ({ ...prevState, unmuteError: null })); + logger.error("Failed to unmute microphone", e); } } + } else { + playClip(PTTClipID.BLOCKED); + blocked = true; } - }, [groupCall, activeSpeakerUserId, isAdmin, talkOverEnabled, setState]); + setState((prevState) => ({ + ...prevState, + pttButtonHeld: true, + transmitBlocked: blocked, + })); + }, [ + pttButtonHeld, + groupCall, + activeSpeakerUserId, + isAdmin, + talkOverEnabled, + setState, + playClip, + ]); const stopTalking = useCallback(() => { - setState((prevState) => ({ ...prevState, pttButtonHeld: false })); + setState((prevState) => ({ + ...prevState, + pttButtonHeld: false, + unmuteError: null, + })); if (!groupCall.isMicrophoneMuted()) { groupCall.setMicrophoneMuted(true); } - setState((prevState) => ({ ...prevState, pttButtonHeld: false })); + setState((prevState) => ({ + ...prevState, + pttButtonHeld: false, + transmitBlocked: false, + })); }, [groupCall]); useEffect(() => { @@ -180,6 +221,6 @@ export const usePTT = ( activeSpeakerUserId, startTalking, stopTalking, - unmuteError, + transmitBlocked, }; }; diff --git a/src/sound/PTTClips.module.css b/src/sound/PTTClips.module.css new file mode 100644 index 0000000..cd680f5 --- /dev/null +++ b/src/sound/PTTClips.module.css @@ -0,0 +1,19 @@ +/* +Copyright 2022 Matrix.org Foundation C.I.C. + +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. +*/ + +.pttClip { + display: none; +} diff --git a/src/sound/PTTClips.tsx b/src/sound/PTTClips.tsx new file mode 100644 index 0000000..e13acb5 --- /dev/null +++ b/src/sound/PTTClips.tsx @@ -0,0 +1,62 @@ +/* +Copyright 2022 Matrix.org Foundation C.I.C. + +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 React from "react"; + +import startTalkLocalOggUrl from "./start_talk_local.ogg"; +import startTalkLocalMp3Url from "./start_talk_local.mp3"; +import startTalkRemoteOggUrl from "./start_talk_remote.ogg"; +import startTalkRemoteMp3Url from "./start_talk_remote.mp3"; +import blockedOggUrl from "./blocked.ogg"; +import blockedMp3Url from "./blocked.mp3"; +import styles from "./PTTClips.module.css"; + +interface Props { + startTalkingLocalRef: React.RefObject; + startTalkingRemoteRef: React.RefObject; + blockedRef: React.RefObject; +} + +export const PTTClips: React.FC = ({ + startTalkingLocalRef, + startTalkingRemoteRef, + blockedRef, +}) => { + return ( + <> + + + + + ); +}; diff --git a/src/sound/blocked.mp3 b/src/sound/blocked.mp3 new file mode 100644 index 0000000..b8b66d6 Binary files /dev/null and b/src/sound/blocked.mp3 differ diff --git a/src/sound/blocked.ogg b/src/sound/blocked.ogg new file mode 100644 index 0000000..697bb53 Binary files /dev/null and b/src/sound/blocked.ogg differ diff --git a/src/sound/start_talk_local.mp3 b/src/sound/start_talk_local.mp3 new file mode 100644 index 0000000..862cd17 Binary files /dev/null and b/src/sound/start_talk_local.mp3 differ diff --git a/src/sound/start_talk_local.ogg b/src/sound/start_talk_local.ogg new file mode 100644 index 0000000..6759a62 Binary files /dev/null and b/src/sound/start_talk_local.ogg differ diff --git a/src/sound/start_talk_remote.mp3 b/src/sound/start_talk_remote.mp3 new file mode 100644 index 0000000..4e24102 Binary files /dev/null and b/src/sound/start_talk_remote.mp3 differ diff --git a/src/sound/start_talk_remote.ogg b/src/sound/start_talk_remote.ogg new file mode 100644 index 0000000..53225c1 Binary files /dev/null and b/src/sound/start_talk_remote.ogg differ diff --git a/src/sound/usePttSounds.ts b/src/sound/usePttSounds.ts new file mode 100644 index 0000000..4084096 --- /dev/null +++ b/src/sound/usePttSounds.ts @@ -0,0 +1,70 @@ +/* +Copyright 2022 Matrix.org Foundation C.I.C. + +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 React, { useCallback, useState } from "react"; + +export enum PTTClipID { + START_TALKING_LOCAL, + START_TALKING_REMOTE, + BLOCKED, +} + +export type PlayClipFunction = (clipID: PTTClipID) => void; + +interface PTTSounds { + startTalkingLocalRef: React.RefObject; + startTalkingRemoteRef: React.RefObject; + blockedRef: React.RefObject; + playClip: PlayClipFunction; +} + +export const usePTTSounds = (): PTTSounds => { + const [startTalkingLocalRef] = useState(React.createRef()); + const [startTalkingRemoteRef] = useState(React.createRef()); + const [blockedRef] = useState(React.createRef()); + + const playClip = useCallback( + async (clipID: PTTClipID) => { + let ref: React.RefObject; + + switch (clipID) { + case PTTClipID.START_TALKING_LOCAL: + ref = startTalkingLocalRef; + break; + case PTTClipID.START_TALKING_REMOTE: + ref = startTalkingRemoteRef; + break; + case PTTClipID.BLOCKED: + ref = blockedRef; + break; + } + if (ref.current) { + ref.current.currentTime = 0; + await ref.current.play(); + } else { + console.log("No media element found"); + } + }, + [startTalkingLocalRef, startTalkingRemoteRef, blockedRef] + ); + + return { + startTalkingLocalRef, + startTalkingRemoteRef, + blockedRef, + playClip, + }; +};