From 72ee0c9ed6422aace4385c6efbeda45434ce31ee Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Mon, 10 Aug 2026 18:32:11 +0000 Subject: [PATCH] Reacquire mic after screen lock kills capture mid-call (#339) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/call.ts | 47 +++++++++++++++++++++++++++++++++++++++ src/routes/+layout.svelte | 4 ++++ 2 files changed, 51 insertions(+) diff --git a/src/app/call.ts b/src/app/call.ts index 1a2d6ad8..3bbaae07 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -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) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 08ad01ac..0096a4f1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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())