From f3131c945d0b3f41a9505dcf01b4b2b35324a298 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 15 Sep 2025 10:29:00 +0100 Subject: [PATCH] Update to delay ringtone logic. --- src/room/InCallView.tsx | 17 +++++++- src/room/WaitingForJoin.module.css | 21 +++++++-- src/useAudioContext.tsx | 68 +++++++++++++++++++++--------- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 383502a3..02c2ce07 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -356,6 +356,16 @@ export const InCallView: FC = ({ const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); useSubscription(vm.autoLeave$, onLeave); + const ringDelay = (pickupPhaseAudio?.soundDuration["waiting"] ?? 1) * 2; + + useEffect(() => { + window.document.body.style.setProperty( + "--call-ring-duration-s", + `${ringDelay}s`, + ); + window.document.body.style.setProperty("--call-ring-delay-s", `1s`); + }, [pickupPhaseAudio?.soundDuration, ringDelay]); + // When we enter timeout or decline we will leave the call. useEffect((): void | (() => void) => { if (callPickupState === "timeout") { @@ -384,13 +394,16 @@ export const InCallView: FC = ({ // When waiting for pickup, loop a waiting sound useEffect((): void | (() => void) => { if (callPickupState !== "ringing" || !pickupPhaseAudio) return; - const endSound = pickupPhaseAudio.playSoundLooping("waiting"); + const endSound = pickupPhaseAudio.playSoundLooping( + "waiting", + ringDelay / 2, + ); return () => { void endSound().catch((e) => { logger.error("Failed to stop ringing sound", e); }); }; - }, [callPickupState, pickupPhaseAudio]); + }, [callPickupState, pickupPhaseAudio, ringDelay]); // Waiting UI overlay const waitingOverlay: JSX.Element | null = useMemo(() => { diff --git a/src/room/WaitingForJoin.module.css b/src/room/WaitingForJoin.module.css index 8f7ebeec..241a99fc 100644 --- a/src/room/WaitingForJoin.module.css +++ b/src/room/WaitingForJoin.module.css @@ -25,10 +25,12 @@ inset: -12px; border-radius: 9999px; border: 12px solid rgba(255, 255, 255, 0.6); - animation: pulse 1.6s ease-out infinite; + animation: pulse var(--call-ring-duration-s) ease-out infinite; + animation-delay: 1s; + opacity: 0; } -.text { +.pulse::before .text { color: var(--cpd-color-text-on-solid-primary); } @@ -36,13 +38,24 @@ 0% { transform: scale(0.95); opacity: 0.7; + transform: scale(0); + opacity: 1; } - 70% { + 35% { transform: scale(1.15); opacity: 0.15; } - 100% { + 50% { transform: scale(1.2); opacity: 0; } + 50.01% { + transform: scale(0); + } + 85% { + transform: scale(0); + } + 100% { + transform: scale(0); + } } diff --git a/src/useAudioContext.tsx b/src/useAudioContext.tsx index 2d5eafd5..3e1da48c 100644 --- a/src/useAudioContext.tsx +++ b/src/useAudioContext.tsx @@ -32,6 +32,8 @@ async function playSound( buffer: AudioBuffer, volume: number, stereoPan: number, + delayS = 0, + abort?: AbortController, ): Promise { const gain = ctx.createGain(); gain.gain.setValueAtTime(volume, 0); @@ -39,10 +41,13 @@ async function playSound( pan.pan.setValueAtTime(stereoPan, 0); const src = ctx.createBufferSource(); src.buffer = buffer; - src.connect(gain).connect(pan).connect(ctx.destination); + abort?.signal.addEventListener("abort", () => { + src.disconnect(); + }); const p = new Promise((r) => src.addEventListener("ended", () => r())); + src.connect(gain).connect(pan).connect(ctx.destination); controls.setPlaybackStarted(); - src.start(); + src.start(ctx.currentTime + delayS); return p; } @@ -60,23 +65,35 @@ function playSoundLooping( buffer: AudioBuffer, volume: number, stereoPan: number, + delayS?: number, ): () => Promise { - const gain = ctx.createGain(); - gain.gain.setValueAtTime(volume, 0); - const pan = ctx.createStereoPanner(); - pan.pan.setValueAtTime(stereoPan, 0); - const src = ctx.createBufferSource(); - src.buffer = buffer; - src.connect(gain).connect(pan).connect(ctx.destination); - controls.setPlaybackStarted(); - src.loop = true; - src.start(); + if (delayS === 0) { + throw Error("Looping sounds must have a delay"); + } + + // Our audio loop + let lastSoundPromise: Promise; + let nextSoundPromise: Promise; + let ac: AbortController | undefined; + void (async () => { + ac = new AbortController(); + // Play a sound immediately + lastSoundPromise = Promise.resolve(); + do { + // Queue up the next sound. + nextSoundPromise = playSound(ctx, buffer, volume, stereoPan, delayS, ac); + // Await the previous sound. + await lastSoundPromise; + // Swap the promises over, and loop round to play the next sound. + lastSoundPromise = nextSoundPromise; + } while (!ac.signal.aborted); + })(); + return async () => { - const p = new Promise((r) => - src.addEventListener("ended", () => r()), - ); - src.stop(); - return p; + ac?.abort(); + // Wait for sounds to finish. + await lastSoundPromise; + await nextSoundPromise; }; } @@ -91,9 +108,13 @@ interface Props { muted?: boolean; } -interface UseAudioContext { +interface UseAudioContext { playSound(soundName: S): Promise; - playSoundLooping(soundName: S): () => Promise; + playSoundLooping(soundName: S, delayS?: number): () => Promise; + /** + * Map of sound name to duration in seconds. + */ + soundDuration: Record; } /** @@ -181,7 +202,7 @@ export function useAudioContext( earpiecePan, ); }, - playSoundLooping: (name): (() => Promise) => { + playSoundLooping: (name, delayS: number): (() => Promise) => { if (!audioBuffers[name]) { throw Error(`Tried to play a sound that wasn't buffered (${name})`); } @@ -190,7 +211,14 @@ export function useAudioContext( audioBuffers[name], soundEffectVolume * earpieceVolume, earpiecePan, + delayS, ); }, + soundDuration: Object.fromEntries( + Object.entries(audioBuffers).map(([k, v]) => [ + k, + (v as AudioBuffer).duration, + ]), + ), }; }