Reacquire mic after screen lock kills capture mid-call (#339)

Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
Aditya Chaudhary 2026-08-10 18:32:11 +00:00 committed by hodlbod
parent 7dcc713ec2
commit 72ee0c9ed6
2 changed files with 51 additions and 0 deletions

View file

@ -14,6 +14,7 @@ import {
supportsAudioOutputSelection,
type AudioCaptureOptions,
} from "livekit-client"
import {App} from "@capacitor/app"
import {derived, get, writable} from "svelte/store"
import {first, not, nthEq, reject, uniqBy} from "@welshman/lib"
import {makeHttpAuth, makeHttpAuthHeader, sortEventsDesc, tagSpec, tagValues} from "@welshman/util"
@ -353,6 +354,25 @@ export const switchCallActiveDevice = async (
}
}
/**
* On mobile, locking the screen can suspend microphone capture without ever
* ending the underlying MediaStreamTrack: the local participant still looks
* connected and unmuted, but publishes silence until the track is manually
* reacquired. LiveKit only guards against this for tracks attached to a DOM
* element (i.e. video), so the mic needs the same treatment on foreground
* return. `App.addListener("appStateChange", ...)` fires from Capacitor's web
* fallback too, so this covers both native and browser tabs.
*/
export const syncCallAudioResume = () => {
const listener = App.addListener("appStateChange", ({isActive}) => {
if (isActive) void reacquireMicrophoneIfNeeded()
})
return () => {
listener.then(l => l.remove())
}
}
export const resetVideoCallLayout = () => {
videoCallLayout.set(VideoCallLayout.Chat)
}
@ -539,6 +559,33 @@ const setUpMicrophone = async (
return muted
}
const reacquireMicrophoneIfNeeded = async () => {
const session = get(currentCallSession)
if (!session || get(callMicMuted)) return
const track = session.livekit.localParticipant.getTrackPublication(
Track.Source.Microphone,
)?.audioTrack
if (!track || track.isMuted || track.isUserProvided) return
// Mirrors LiveKit's own (mobile-only, video-track-only) reacquisition
// check: a capture device that died silently still reports readyState
// "live", but the browser flips `muted`/`enabled` on the underlying
// MediaStreamTrack. Checking for actual silence instead would false-
// positive any time the user simply isn't talking.
const {mediaStreamTrack} = track
const needsReacquisition =
mediaStreamTrack.readyState !== "live" || mediaStreamTrack.muted || !mediaStreamTrack.enabled
if (!needsReacquisition) return
try {
await track.restartTrack()
} catch {
// Best-effort: the user can still recover via mute/unmute or by
// rejoining if reacquiring the mic fails here.
}
}
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)

View file

@ -37,6 +37,7 @@
import {Push} from "@app/push"
import {onPushNotificationAction, pushState} from "@app/push/adapters/common"
import {syncKeyboard} from "@app/keyboard"
import {syncCallAudioResume} from "@app/call"
import {getPageTitle} from "@app/title"
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
@ -163,6 +164,9 @@
// Initialize keyboard state tracking
unsubscribers.push(syncKeyboard())
// Reacquire the mic if the screen locked mid-call and capture died
unsubscribers.push(syncCallAudioResume())
// Subscribe to badge count for changes
unsubscribers.push(notifications.syncBadges())