Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d5be70f
chore: removed redundant callmanager.setup call
greenfrvr Jun 15, 2026
c852a3d
chore: made lobby preview independent from webrtc
greenfrvr Jun 15, 2026
95882a8
feat: adjusted peer connection factory instantiation
greenfrvr Jun 25, 2026
2048b9f
feat: adjusted callingx audio engine wiring to match adm lifecycle
greenfrvr Jun 25, 2026
6dfa824
chore: restricted call manager invocations to call join/left window
greenfrvr Jul 2, 2026
dbfeb4b
chore: bound CallManager start/stop to call lifecycle
greenfrvr Jul 2, 2026
e67341c
Merge branch 'main' into hifi-audio
greenfrvr Jul 13, 2026
3b6b6ba
chore: adjusted telecom managed incallmanager logic
greenfrvr Jul 13, 2026
c7c09a2
chore: fixed join/leave race preventing redundant factory creation
greenfrvr Jul 15, 2026
2e863d3
chore: prevent double publishing
greenfrvr Jul 16, 2026
29f635f
chore: added RN guard to setAudioBitrateProfile
greenfrvr Jul 17, 2026
0c456d0
chore: updated stereo output enable condition
greenfrvr Jul 17, 2026
f313bfb
chore: code cleanup
greenfrvr Jul 21, 2026
85409e1
Merge branch 'main' into hifi-audio
greenfrvr Jul 31, 2026
b9b4de9
chore: resolving merge changes
greenfrvr Jul 31, 2026
98c48cc
Merge branch 'main' into hifi-audio
greenfrvr Aug 6, 2026
7007a8d
chore: fixed setEngineAvailability potential race
greenfrvr Aug 6, 2026
017bf65
chore: adjusted apply speaker condition
greenfrvr Aug 7, 2026
c740cd9
chore: adjusted sdp munging for enabling stereo
greenfrvr Aug 7, 2026
b9cac85
chore: added hifi_audio_enabled guard for stereo output
greenfrvr Aug 10, 2026
14c9d1d
chore: made bitrate profile enable stereo output
greenfrvr Aug 10, 2026
5c529a8
Revert "chore: adjusted sdp munging for enabling stereo"
greenfrvr Aug 11, 2026
dea2f2a
chore: adjusted public api for enabling stereo output
greenfrvr Aug 11, 2026
fb6fdd6
chore: removed redundant todo
greenfrvr Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './src/stats/types';

export * from './src/Call';
export * from './src/CallType';
export * from './src/rtc/mediaEngine';
export * from './src/StreamVideoClient';
export * from './src/StreamSfuClient';
export * from './src/devices';
Expand Down
110 changes: 108 additions & 2 deletions packages/client/src/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { StreamSfuClient } from './StreamSfuClient';
import { SfuJoinError } from './errors';
import {
BasePeerConnectionOpts,
type CallMediaEngine,
Dispatcher,
getCallMediaEngineProvider,
getGenericSdp,
isAudioTrackType,
isSfuEvent,
Expand Down Expand Up @@ -328,6 +330,8 @@ export class Call {
private allowOwnTracksLoopback = false;
private hasJoinedOnce = false;
private deviceSettingsAppliedOnce = false;
private callManagerStarted = false;
private leaveGeneration = 0;
private credentials?: Credentials;

private initialized = false;
Expand All @@ -351,6 +355,14 @@ export class Call {
ClientCapability.SUBSCRIBER_VIDEO_PAUSE,
]);

/**
* The in-flight per-call media engine. On web/React this resolves to a thin
* globals-backed engine (no provider registered); React Native registers a
* provider that owns a per-call native factory.
* @internal
*/
private mediaEnginePromise?: Promise<CallMediaEngine>;

/**
* Constructs a new `Call` instance.
*
Expand Down Expand Up @@ -698,6 +710,8 @@ export class Call {
return;
}

this.leaveGeneration += 1;

if (callingState === CallingState.JOINING) {
const waitUntilCallJoined = () => {
return new Promise<void>((resolve) => {
Expand Down Expand Up @@ -798,13 +812,15 @@ export class Call {

globalThis.streamRNVideoSDK?.callManager.stop({
isRingingTypeCall: this.ringing,
shouldStopCallManager: this.callManagerStarted,
});

this.camera.dispose();
this.microphone.dispose();
this.screenShare.dispose();
this.speaker.dispose();
this.deviceSettingsAppliedOnce = false;
this.callManagerStarted = false;

const stopOnLeavePromises: Promise<void>[] = [];
if (this.camera.stopOnLeave) {
Expand All @@ -817,6 +833,23 @@ export class Call {
stopOnLeavePromises.push(this.screenShare.disable(true));
}
await Promise.all(stopOnLeavePromises);

// Dispose the per-call media engine last — after peer connections and
// local tracks are gone — so the backing factory tears down with no
// owned PCs/tracks. A fresh `join()` builds a new engine.
if (this.mediaEnginePromise) {
const enginePromise = this.mediaEnginePromise;
this.mediaEnginePromise = undefined;
this.logger.debug('Disposing per-call media factory');
await enginePromise
.then((engine) => {
globalThis.streamRNVideoSDK?.callingX?.unwireAudioEngineSubscription();
return engine.dispose();
})
.catch((err) => {
this.logger.warn('Failed to dispose media engine', err);
});
}
});
};

Expand Down Expand Up @@ -1189,12 +1222,26 @@ export class Call {
private doJoin = async (data?: JoinCallData): Promise<void> => {
const connectStartTime = Date.now();
const callingState = this.state.callingState;
const joinLeaveGeneration = this.leaveGeneration;
const supersededByLeave = () =>
this.leaveGeneration !== joinLeaveGeneration;

this.joinCallData = data;

this.logger.debug('Starting join flow');
this.state.setCallingState(CallingState.JOINING);

// Ensure the per-call media engine exists before any peer connection
// (codec probe, subscriber, publisher) or capture happens, so the WebRTC
// globals resolve to the call's factory. Idempotent across
// reconnect/migration attempts.
await this.ensureMediaFactory();

const callingX = globalThis.streamRNVideoSDK?.callingX;
if (callingX) {
callingX.wireAudioEngineSubscription();
}

Comment on lines +1218 to +1237

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file sections with line numbers.
sed -n '1140,1410p' packages/client/src/Call.ts | cat -n

# Also locate the join/leave generation checks and retry loop entry points.
rg -n "joinLeaveGeneration|supersededByLeave|wireAudioEngineSubscription|unwireAudioEngineSubscription|doJoin|join\(" packages/client/src/Call.ts

Repository: GetStream/stream-video-js

Length of output: 13989


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the join() wrapper and the two bailout regions in doJoin().
sed -n '1100,1405p' packages/client/src/Call.ts | cat -n

# Focus on the exact bailout blocks and any code that runs after them.
sed -n '1298,1395p' packages/client/src/Call.ts | cat -n

# Inspect leave/dispose behavior around the audio-engine unsubscribe path.
sed -n '760,870p' packages/client/src/Call.ts | cat -n

Repository: GetStream/stream-video-js

Length of output: 23162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for an existing supersession/abort error or related join-flow handling.
rg -n "superseded|abort.*join|join.*abort|JOIN_SUPER|leave superseded|giveUpAndLeave|doJoin\(" packages/client/src

# Inspect any tests or call sites that rely on join() resolving/rejecting in this scenario.
rg -n "call\.join\(|\.join\(\{|\bjoin\(\)" packages/client/src packages/client/test packages/client/src/__tests__ || true

Repository: GetStream/stream-video-js

Length of output: 5013


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the tail of doJoin() and nearby guards that run after the supersession check.
sed -n '1400,1465p' packages/client/src/Call.ts | cat -n

# Inspect the join-rejection caching / retry-related logic referenced in comments.
sed -n '1745,1788p' packages/client/src/Call.ts | cat -n

# Inspect the giveUpAndLeave path inside reconnect handling.
sed -n '1848,2025p' packages/client/src/Call.ts | cat -n

Repository: GetStream/stream-video-js

Length of output: 13668


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where leaveGeneration is incremented/used and how leave synchronizes with join.
rg -n "leaveGeneration|this\.leaveGeneration|generation" packages/client/src/Call.ts

# Inspect the leave implementation around the points that advance generation and dispose the engine.
sed -n '700,860p' packages/client/src/Call.ts | cat -n

# Inspect the join-related call sites that might wait on join/leave sequencing.
sed -n '2035,2145p' packages/client/src/Call.ts | cat -n

Repository: GetStream/stream-video-js

Length of output: 12263


Reject superseded joins instead of resolving them

In packages/client/src/Call.ts, both supersession bailouts currently return normally, so call.join() can resolve even though a concurrent leave() prevented JOINED from ever being set. callingX.wireAudioEngineSubscription() also runs before the first supersession check, which can reattach the audio engine after leave() has already unwired/disposed it. Surface a distinct superseded error from these exits and guard the wiring call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/Call.ts` around lines 1225 - 1244, The join flow in
Call.join must treat superseded joins as failures: add a distinct superseded
error and throw it from both existing supersession bailouts instead of returning
normally. Move or guard callingX.wireAudioEngineSubscription() with the first
supersession check so it cannot reattach the audio engine after leave() has
unwired or disposed it, while preserving normal joining behavior.

const performingMigration =
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
const performingRejoin =
Expand Down Expand Up @@ -1265,6 +1312,11 @@ export class Call {
// the capabilities of the client (codec support, etc.)
const { dangerouslyForceCodec, fmtpLine, subscriberFmtpLine } =
this.clientPublishOptions || {};
// skip if a leave superseded this join so codec detection doesn't resolve to a default factory.
if (supersededByLeave()) {
this.logger.debug('Join superseded by leave; skipping codec detection');
return;
}
const [subscriberSdp, publisherSdp] = await Promise.all([
getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
Expand Down Expand Up @@ -1324,6 +1376,13 @@ export class Call {
}
}

// If the user left while this join was in flight, bail before re-setting JOINED and before
// peer-connection setup below (both run synchronously after this, so one check covers them).
if (supersededByLeave()) {
this.logger.debug('Join superseded by leave; aborting join flow');
return;
}

if (!performingMigration) {
// in MIGRATION, `JOINED` state is set in `this.reconnectMigrate()`
this.state.setCallingState(CallingState.JOINED);
Expand Down Expand Up @@ -1370,12 +1429,21 @@ export class Call {

// device settings should be applied only once, we don't have to
// re-apply them on later reconnections or server-side data fetches
if (!this.deviceSettingsAppliedOnce && this.state.settings) {
if (
!this.deviceSettingsAppliedOnce &&
this.state.settings &&
!supersededByLeave()
) {
await this.applyDeviceConfig(this.state.settings, true, false);
this.deviceSettingsAppliedOnce = true;
}

if (!this.callManagerStarted && !supersededByLeave()) {
globalThis.streamRNVideoSDK?.callManager.start({
isRingingTypeCall: this.ringing,
cid: this.cid,
});
this.deviceSettingsAppliedOnce = true;
this.callManagerStarted = true;
}

// We shouldn't persist the `ring` and `notify` state after joining the call
Expand Down Expand Up @@ -1662,6 +1730,44 @@ export class Call {
return joinResponse;
};

/**
* Whether the per-call media engine currently exists. True from join until leave.
*
* @internal an internal getter and should not be used outside the SDK.
*/
get hasMediaEngine(): boolean {
return !!this.mediaEnginePromise;
}

/**
* Ensures a {@link CallMediaEngine} exists for this call's media session and
* returns it. Idempotent: the engine is created once via the registered
* provider (see `setCallMediaEngineProvider`) and cached until `leave()`
* disposes it. Concurrent callers (e.g. camera + microphone enabling in
* parallel) share the same engine because the in-flight creation promise is
* cached, never the unresolved result.
*
* @internal
*/
ensureMediaFactory = async (): Promise<CallMediaEngine> => {
if (!this.mediaEnginePromise) {
const provider = getCallMediaEngineProvider();

const audioBitrateProfile = this.microphone.state.audioBitrateProfile;
this.logger.debug(
`Requesting per-call media factory creation (audioBitrateProfile=${audioBitrateProfile ?? 'default'})`,
);
this.mediaEnginePromise = Promise.resolve(
provider({ audioBitrateProfile }),
).catch((err) => {
// Drop the cached rejection so a retried join() can rebuild the engine
this.mediaEnginePromise = undefined;
throw err;
});
}
return this.mediaEnginePromise;
};

/**
* Handles the closing of the SFU signal connection.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/devices/AudioDeviceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DeviceManager } from './DeviceManager';
import { AudioDeviceManagerState } from './AudioDeviceManagerState';
import { AudioBitrateProfile } from '../gen/video/sfu/models/models';
import { TrackPublishOptions } from '../rtc';
import { isReactNative } from '../helpers/platforms';

/**
* Base class for High Fidelity enabled Device Managers.
Expand All @@ -17,6 +18,11 @@ export abstract class AudioDeviceManager<
if (!this.call.state.settings?.audio.hifi_audio_enabled) {
throw new Error('High Fidelity audio is not enabled for this call');
}
if (isReactNative() && this.call.hasMediaEngine) {
throw new Error(
'setAudioBitrateProfile must be called before joining the call.',
);
}
this.doSetAudioBitrateProfile(profile);
this.state.setAudioBitrateProfile(profile);
if (this.enabled) {
Expand Down
65 changes: 61 additions & 4 deletions packages/client/src/devices/CameraManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { VideoSettingsResponse } from '../gen/coordinator';
import { TrackType } from '../gen/video/sfu/models/models';
import { isMobile } from '../helpers/compatibility';
import { isReactNative } from '../helpers/platforms';
import { CallingState } from '../store';
import { DevicePersistenceOptions } from './devicePersistence';

export class CameraManager extends DeviceManager<CameraManagerState> {
Expand Down Expand Up @@ -124,6 +125,53 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
}
}

override enable(): Promise<void> {
if (
isReactNative() &&
this.call.state.callingState !== CallingState.JOINED
) {
this.state.setPendingStatus('enabled');
return Promise.resolve();
}

return super.enable();
}

override disable(options: { forceStop?: boolean }): Promise<void>;
override disable(forceStop?: boolean): Promise<void>;
override async disable(
forceStopOrOptions?: boolean | { forceStop?: boolean },
): Promise<void> {
if (
isReactNative() &&
this.call.state.callingState !== CallingState.JOINED
) {
this.state.setPendingStatus('disabled');
return;
}

// forward verbatim to the base, narrowing so the right overload is selected
if (forceStopOrOptions === undefined) return super.disable();
if (typeof forceStopOrOptions === 'boolean') {
return super.disable(forceStopOrOptions);
}
return super.disable(forceStopOrOptions);
}

override toggle(): Promise<void> {
if (
isReactNative() &&
this.call.state.callingState !== CallingState.JOINED
) {
this.state.setPendingStatus(
this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
);
return Promise.resolve();
}

return super.toggle();
}

Comment thread
greenfrvr marked this conversation as resolved.
/**
* Applies the video settings to the camera.
*
Expand Down Expand Up @@ -166,9 +214,15 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
}
}

const { mediaStream } = this.state;
if (canPublish && publish && this.enabled && mediaStream) {
await this.publishStream(mediaStream);
if (isReactNative() && publish && canPublish) {
// On RN the camera is enabled/disabled optimistically before JOINED. Reconcile now
// acquires the track and publishes it, so it fully owns the publish.
await this.reconcileOptimisticStatus();
} else {
const { mediaStream } = this.state;
if (canPublish && publish && this.enabled && mediaStream) {
await this.publishStream(mediaStream);
}
}
}

Expand Down Expand Up @@ -196,9 +250,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
return constraints;
}

protected override getStream(
protected override async getStream(
constraints: MediaTrackConstraints,
): Promise<MediaStream> {
// Ensure the call's media factory exists before capture so the resulting
// track is owned by it (the WebRTC globals resolve to the live factory).
await this.call.ensureMediaFactory();
return getVideoStream(constraints, this.call.tracer);
}
}
17 changes: 17 additions & 0 deletions packages/client/src/devices/DeviceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,23 @@ export abstract class DeviceManager<
}
}

protected reconcileOptimisticStatus = async (): Promise<void> => {
const target = this.state.optimisticStatus;
await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
try {
if (target === 'enabled' && this.state.status !== 'enabled') {
await this.unmuteStream();
if (!signal.aborted) this.state.setStatus('enabled');
} else if (target === 'disabled' && this.state.status === 'enabled') {
// mirror whatever disable() does to stop/pause the track per disableMode
if (!signal.aborted) this.state.setStatus('disabled');
}
} finally {
if (!signal.aborted) this.state.setPendingStatus(this.state.status);
}
});
};

Comment on lines +512 to +528

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Missing stream muting when reconciling to disabled state.

When reconciling the status from enabled to disabled, the code only updates the state but fails to actually mute or stop the underlying media tracks. This leaves the camera/microphone physically active despite the SDK reporting them as disabled.

You must invoke muteStream just like the standard disable() method does.

🐛 Proposed fix
   protected reconcileOptimisticStatus = async (): Promise<void> => {
     const target = this.state.optimisticStatus;
     await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
       try {
         if (target === 'enabled' && this.state.status !== 'enabled') {
           await this.unmuteStream();
           if (!signal.aborted) this.state.setStatus('enabled');
         } else if (target === 'disabled' && this.state.status === 'enabled') {
-          // mirror whatever disable() does to stop/pause the track per disableMode
+          const stopTracks = this.state.disableMode === 'stop-tracks';
+          await this.muteStream(stopTracks);
           if (!signal.aborted) this.state.setStatus('disabled');
         }
       } finally {
         if (!signal.aborted) this.state.setPendingStatus(this.state.status);
       }
     });
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
protected reconcileOptimisticStatus = async (): Promise<void> => {
const target = this.state.optimisticStatus;
await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
try {
if (target === 'enabled' && this.state.status !== 'enabled') {
await this.unmuteStream();
if (!signal.aborted) this.state.setStatus('enabled');
} else if (target === 'disabled' && this.state.status === 'enabled') {
// mirror whatever disable() does to stop/pause the track per disableMode
if (!signal.aborted) this.state.setStatus('disabled');
}
} finally {
if (!signal.aborted) this.state.setPendingStatus(this.state.status);
}
});
};
protected reconcileOptimisticStatus = async (): Promise<void> => {
const target = this.state.optimisticStatus;
await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
try {
if (target === 'enabled' && this.state.status !== 'enabled') {
await this.unmuteStream();
if (!signal.aborted) this.state.setStatus('enabled');
} else if (target === 'disabled' && this.state.status === 'enabled') {
const stopTracks = this.state.disableMode === 'stop-tracks';
await this.muteStream(stopTracks);
if (!signal.aborted) this.state.setStatus('disabled');
}
} finally {
if (!signal.aborted) this.state.setPendingStatus(this.state.status);
}
});
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/devices/DeviceManager.ts` around lines 512 - 528, Update
reconcileOptimisticStatus so the target === 'disabled' branch invokes
muteStream, matching the behavior of disable(), before setting the state to
disabled. Preserve the existing abort guard and pending-status reconciliation
while ensuring the underlying media tracks are actually muted or stopped.

private disableTracks() {
this.getTracks().forEach((track) => {
if (track.enabled) track.enabled = false;
Expand Down
Loading
Loading