Gate incoming-call ring audio on App.isActive to stop double-ring in the brief window after app backgrounding
This commit is contained in:
parent
0692c05408
commit
0c30e37b70
1 changed files with 49 additions and 6 deletions
|
|
@ -6,17 +6,24 @@
|
||||||
// IncomingCallStripRenderer is platform-agnostic: if JS knows about an incoming
|
// IncomingCallStripRenderer is platform-agnostic: if JS knows about an incoming
|
||||||
// ring, we render the in-app strip. On Android the native FCM service decides
|
// ring, we render the in-app strip. On Android the native FCM service decides
|
||||||
// independently whether to surface a system CallStyle notification; when the
|
// independently whether to surface a system CallStyle notification; when the
|
||||||
// app is foregrounded it now suppresses that banner, so this renderer no longer
|
// app is foregrounded it suppresses that banner, so strip render itself does
|
||||||
// needs to mirror native foreground policy in JS.
|
// not need to mirror foreground policy in JS.
|
||||||
|
//
|
||||||
|
// Ring audio DOES mirror foreground policy — gated on `appActive` (techdebt
|
||||||
|
// §5.39): when the app/tab is hidden the platform surface (Android CallStyle /
|
||||||
|
// web SW push) owns ringtone UX, and playing the in-app <audio> on top would
|
||||||
|
// double-ring during the grace window after backgrounding while the WebView
|
||||||
|
// still processes /sync.
|
||||||
//
|
//
|
||||||
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no
|
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no
|
||||||
// user gesture yet), the ring is silent — strip is still visible but user may
|
// user gesture yet), the ring is silent — strip is still visible but user may
|
||||||
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is
|
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is
|
||||||
// Phase 3 polish.
|
// Phase 3 polish.
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { Box } from 'folds';
|
import { Box } from 'folds';
|
||||||
|
import { App } from '@capacitor/app';
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { IncomingCallStrip } from '../features/call-status';
|
import { IncomingCallStrip } from '../features/call-status';
|
||||||
|
|
@ -29,22 +36,58 @@ export function IncomingCallStripRenderer() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const incoming = useAtomValue(incomingCallsAtom);
|
const incoming = useAtomValue(incomingCallsAtom);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [appActive, setAppActive] = useState(
|
||||||
|
() => typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||||
|
);
|
||||||
|
|
||||||
const hasIncoming = incoming.size > 0;
|
const hasIncoming = incoming.size > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
// Listener registration and getState() race: if a real appStateChange
|
||||||
|
// fires before getState() resolves, the stale getState() snapshot would
|
||||||
|
// overwrite the fresh event and re-open §5.39 (e.g. background → listener
|
||||||
|
// sets false → late getState() resolves true → audio re-enabled).
|
||||||
|
let sawAppStateEvent = false;
|
||||||
|
let remove: (() => void) | undefined;
|
||||||
|
|
||||||
|
App.addListener('appStateChange', ({ isActive }) => {
|
||||||
|
sawAppStateEvent = true;
|
||||||
|
if (!cancelled) setAppActive(isActive);
|
||||||
|
}).then((handle) => {
|
||||||
|
if (cancelled) handle.remove();
|
||||||
|
else
|
||||||
|
remove = () => {
|
||||||
|
handle.remove();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
App.getState()
|
||||||
|
.then((state) => {
|
||||||
|
if (!cancelled && !sawAppStateEvent) setAppActive(state.isActive);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* web fallback handled by initial document.visibilityState */
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
remove?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
if (hasIncoming) {
|
if (hasIncoming && appActive) {
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
audio.play().catch(() => {
|
audio.play().catch(() => {
|
||||||
// autoplay blocked — strip UI still visible
|
// autoplay blocked — strip UI still visible
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.currentTime = 0;
|
|
||||||
}
|
}
|
||||||
}, [hasIncoming]);
|
}, [hasIncoming, appActive]);
|
||||||
|
|
||||||
const entries = Array.from(incoming.values());
|
const entries = Array.from(incoming.values());
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue