diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e87072d3..7a8753b9 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -112,6 +112,12 @@ import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; import { Toast } from "../Toast.tsx"; +import { Avatar, Size as AvatarSize } from "../Avatar"; +import waitingStyles from "./WaitingForJoin.module.css"; +import { prefetchSounds } from "../soundUtils"; +import { useAudioContext } from "../useAudioContext"; +// TODO: Dont use this!!! use the correct sound +import { GenericReaction } from "../reactions"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -265,6 +271,23 @@ export const InCallView: FC = ({ }); const muteAllAudio = useBehavior(muteAllAudio$); + // Call pickup state and display names are needed for waiting overlay/sounds + const callPickupState = useBehavior(vm.callPickupState$); + const displaynames = useBehavior(vm.memberDisplaynames$); + // Preload a subtle waiting/ringing tone + const [waitingSoundCache, setWaitingSoundCache] = useState | null>(null); + useEffect(() => { + if (!waitingSoundCache && callPickupState === "ringing") { + setWaitingSoundCache(prefetchSounds({ waiting: GenericReaction.sound! })); + } + }, [waitingSoundCache, callPickupState]); + const waitingAudio = useAudioContext({ + sounds: waitingSoundCache, + latencyHint: "interactive", + muted: muteAllAudio, + }); // This seems like it might be enough logic to use move it into the call view model? const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); @@ -328,6 +351,61 @@ export const InCallView: FC = ({ const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); useSubscription(vm.autoLeave$, onLeave); + // When waiting for pickup, loop a quiet waiting sound + useEffect((): void | (() => void) => { + // if (callPickupState !== "ringing") return; + // play immediately and then every ~2.5s while in ringing + void waitingAudio?.playSound("waiting"); + const id = window.setInterval( + () => void waitingAudio?.playSound("waiting"), + 2500, + ); + return (): void => window.clearInterval(id); + }, [callPickupState, waitingAudio]); + + // Waiting UI overlay + const waitingOverlay: JSX.Element | null = useMemo(() => { + // if (callPickupState !== "ringing") return null; + + // Fallback to room state (joined or invited members) + const roomOthers = [ + ...matrixRoom.getMembersWithMembership("join"), + ...matrixRoom.getMembersWithMembership("invite"), + ].filter((m) => m.userId !== client.getUserId()); + const roomOtherIds = Array.from(new Set(roomOthers.map((m) => m.userId))); + + const isOneOnOne = roomOtherIds.length === 1; + const otherId = isOneOnOne ? roomOtherIds[0] : undefined; + const otherMember = isOneOnOne + ? (roomOthers.find((m) => m.userId === otherId) ?? + matrixRoom.getMember(otherId!)) + : null; + const name: string = isOneOnOne + ? (displaynames.get(otherId!) ?? otherMember?.name ?? otherId!) + : "Other participants"; + const avatarMxc = otherMember?.getMxcAvatarUrl?.() ?? undefined; + const text = isOneOnOne + ? `Waiting for ${name} to join…` + : "Waiting for other participants…"; + return ( +
+
+
+ +
+ + {text} + +
+
+ ); + }, [client, displaynames, matrixRoom]); + // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported // in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility @@ -806,6 +884,7 @@ export const InCallView: FC = ({ onBackToVideoPressed={audioOutputSwitcher?.switch} /> + {waitingOverlay} {footer} {layout.type !== "pip" && ( <> diff --git a/src/room/WaitingForJoin.module.css b/src/room/WaitingForJoin.module.css new file mode 100644 index 00000000..58f44e48 --- /dev/null +++ b/src/room/WaitingForJoin.module.css @@ -0,0 +1,51 @@ +.overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + background: var(--cpd-color-bg-canvas-default); + opacity: 0.94; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.pulse { + position: relative; + height: 90px; +} + +.pulse::before { + content: ""; + position: absolute; + inset: -12px; + border-radius: 9999px; + border: 2px solid rgba(255, 255, 255, 0.6); + animation: pulse 1.6s ease-out infinite; +} + +@keyframes pulse { + 0% { + transform: scale(0.95); + opacity: 0.7; + } + 70% { + transform: scale(1.15); + opacity: 0.15; + } + 100% { + transform: scale(1.2); + opacity: 0; + } +} + +.label { + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); +}