Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2107e3d
fix: adjusted shouldRejectCall implementation (#2072)
greenfrvr Dec 29, 2025
d62ca2b
fix: replace non-compliant foreground service types (#2058)
santhoshvai Dec 30, 2025
1b04c58
chore(@stream-io/video-client): release version 1.39.3
Dec 30, 2025
be90549
chore(@stream-io/video-react-bindings): release version 1.12.6
Dec 30, 2025
a43c775
chore(@stream-io/video-react-sdk): release version 1.30.1
Dec 30, 2025
9600208
chore(@stream-io/video-react-native-sdk): release version 1.26.6
Dec 30, 2025
d62d807
chore(@stream-io/video-codemod): release version 0.0.1
Dec 30, 2025
328e6f8
chore(@stream-io/video-react-native-dogfood): release version 4.28.6
Dec 30, 2025
4105ee7
feat(react-native): expose useModeration hook (#2073)
oliverlaz Dec 30, 2025
b16ffc0
fix: correctly restore background blur if available
oliverlaz Dec 30, 2025
af7a6cd
chore(@stream-io/video-react-native-sdk): release version 1.27.0
Dec 30, 2025
c44880c
chore(@stream-io/video-react-native-dogfood): release version 4.29.0
Dec 30, 2025
cee1282
chore(pronto): make QR code section collapsible (#2075)
jdimovska Dec 31, 2025
be82657
feat(react): Add Grid View During PIP (#2076)
jdimovska Dec 31, 2025
9ea702f
fix(react): React Compiler strips memoization and causes MenuPortal t…
jdimovska Jan 5, 2026
6c784f0
feat: Call Stats Map (#2025)
oliverlaz Jan 5, 2026
73c0414
chore(@stream-io/video-client): release version 1.40.0
Jan 9, 2026
ab71874
chore(@stream-io/video-react-bindings): release version 1.12.7
Jan 9, 2026
1277069
chore(@stream-io/video-styling): release version 1.10.0
Jan 9, 2026
a3ff180
chore(@stream-io/video-react-sdk): release version 1.31.0
Jan 9, 2026
7c70caf
chore(@stream-io/video-react-native-sdk): release version 1.27.1
Jan 9, 2026
c7a6ead
chore(@stream-io/video-react-native-dogfood): release version 4.29.1
Jan 9, 2026
3529c8f
fix: ensure proper set up of server-side preferences for mic and came…
oliverlaz Jan 9, 2026
f3059cd
init implementation
santhoshvai Jan 14, 2026
701fec7
feat: add foreground service for StreamCallKeepAlive in AndroidManifest
santhoshvai Jan 14, 2026
dbc3c0e
fix: resolve race condition in device preference setup for audio and …
santhoshvai Jan 14, 2026
3df173a
Revert "fix: resolve race condition in device preference setup for au…
santhoshvai Jan 14, 2026
12042cc
manifest update
santhoshvai Jan 14, 2026
db9bc74
update type compuation
santhoshvai Jan 14, 2026
715e6fb
remove the checker for service decalration
santhoshvai Jan 14, 2026
8037eb5
remove DEVICE_POWER permission from AndroidManifest files
santhoshvai Jan 14, 2026
1b976b8
removing unnecessary changes
santhoshvai Jan 14, 2026
be1ed4c
removing unnecessary changes
santhoshvai Jan 14, 2026
60f6eda
removing unnecessary changes
santhoshvai Jan 14, 2026
c221065
removing unnecessary changes
santhoshvai Jan 14, 2026
7b4c647
removing unnecessary changes
santhoshvai Jan 14, 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 eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default [
'react/no-unescaped-entities': 'off',
'react/react-in-jsx-scope': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/purity': 'error',
'react-hooks/exhaustive-deps': 'error',
'react-compiler/react-compiler': 'off',
},
Expand Down
13 changes: 13 additions & 0 deletions packages/client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).

## [1.40.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.39.3...@stream-io/video-client-1.40.0) (2026-01-09)

### Features

- Call Stats Map ([#2025](https://github.com/GetStream/stream-video-js/issues/2025)) ([6c784f0](https://github.com/GetStream/stream-video-js/commit/6c784f0acacce3d23d0f589ff423d6a0d04c1e95))

## [1.39.3](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.39.2...@stream-io/video-client-1.39.3) (2025-12-30)

### Bug Fixes

- adjusted shouldRejectCall implementation ([#2072](https://github.com/GetStream/stream-video-js/issues/2072)) ([2107e3d](https://github.com/GetStream/stream-video-js/commit/2107e3db65309664a7797cacae054aeb7a371f4a))
- **rpc:** Reliable SFU request timeouts ([#2066](https://github.com/GetStream/stream-video-js/issues/2066)) ([f842b74](https://github.com/GetStream/stream-video-js/commit/f842b74109af02c8454f5ff4f6618baac650ed4e))

## [1.39.2](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.39.1...@stream-io/video-client-1.39.2) (2025-12-23)

- upgrade stream dependencies ([#2065](https://github.com/GetStream/stream-video-js/issues/2065)) ([04ca858](https://github.com/GetStream/stream-video-js/commit/04ca858517072f861c1ddae0876f0b425ca658e2))
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stream-io/video-client",
"version": "1.39.2",
"version": "1.40.0",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
"browser": "dist/index.browser.es.js",
Expand Down
22 changes: 22 additions & 0 deletions packages/client/src/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import type {
QueryCallMembersResponse,
QueryCallSessionParticipantStatsResponse,
QueryCallSessionParticipantStatsTimelineResponse,
QueryCallStatsMapResponse,
RejectCallRequest,
RejectCallResponse,
RequestPermissionRequest,
Expand Down Expand Up @@ -2630,6 +2631,27 @@ export class Call {
});
};

/**
* Retrieves the call stats for the current call session in a format suitable
* for displaying in map-like UIs.
*/
getCallStatsMap = async (
params: {
start_time?: Date | string;
end_time?: Date | string;
exclude_publishers?: boolean;
exclude_subscribers?: boolean;
exclude_sfus?: boolean;
} = {},
callSessionId: string | undefined = this.state.session?.id,
): Promise<QueryCallStatsMapResponse> => {
if (!callSessionId) throw new Error('callSessionId is required');
return this.streamClient.get<QueryCallStatsMapResponse>(
`${this.streamClient.baseURL}/call_stats/${this.type}/${this.id}/${callSessionId}/map`,
params,
);
};

/**
* Sends a custom event to all call participants.
*
Expand Down
1 change: 0 additions & 1 deletion packages/client/src/StreamVideoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,6 @@ export class StreamVideoClient {
(c) =>
c.cid !== currentCallId &&
c.ringing &&
!c.isCreatedByMe &&
c.state.callingState !== CallingState.IDLE &&
c.state.callingState !== CallingState.LEFT &&
c.state.callingState !== CallingState.RECONNECTING_FAILED,
Expand Down
167 changes: 153 additions & 14 deletions packages/client/src/__tests__/StreamVideoClient.ringing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import 'dotenv/config';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { StreamVideoClient } from '../StreamVideoClient';
import { StreamClient } from '@stream-io/node-sdk';
import { StreamVideoEvent } from '../coordinator/connection/types';
import {
StreamClientOptions,
StreamVideoEvent,
} from '../coordinator/connection/types';
import { CallingState } from '../store';
import { settled } from '../helpers/concurrency';
import { getCallInitConcurrencyTag } from '../helpers/clientUtils';
Expand All @@ -14,35 +17,41 @@ const secret = process.env.STREAM_SECRET!;

describe('StreamVideoClient Ringing', () => {
const serverClient = new StreamClient(apiKey, secret);
const testUserIds = ['oliver', 'sacha', 'marcelo'];

let oliverClient: StreamVideoClient;
let sachaClient: StreamVideoClient;
let marceloClient: StreamVideoClient;

beforeEach(async () => {
const createClients = async (
userIds: string[],
clientOptions?: StreamClientOptions,
) => {
const makeClient = async (userId: string) => {
const client = new StreamVideoClient(apiKey, {
// tests run in node, so we have to fake being in browser env
browser: true,
timeout: 15000,
});
const client = new StreamVideoClient(apiKey, clientOptions);
const token = serverClient.generateUserToken({ user_id: userId });
await client.connectUser({ id: userId }, token);
return client;
};
[oliverClient, sachaClient, marceloClient] = await Promise.all([
makeClient('oliver'),
makeClient('sacha'),
makeClient('marcelo'),
]);
});

afterEach(async () => {
return await Promise.all(userIds.map(makeClient));
};

const disconnectClients = async () => {
await Promise.all([
oliverClient.disconnectUser(),
sachaClient.disconnectUser(),
marceloClient.disconnectUser(),
]);
};

beforeEach(async () => {
[oliverClient, sachaClient, marceloClient] =
await createClients(testUserIds);
});

afterEach(async () => {
await disconnectClients();
});

describe('standard ringing', async () => {
Expand Down Expand Up @@ -213,4 +222,134 @@ describe('StreamVideoClient Ringing', () => {
expect(call.ringing).toBe(true);
});
});

describe('ringing interruption', () => {
const setupInitialCall = async () => {
const sachaRing = expectEvent(sachaClient, 'call.ring');
const oliverCall = oliverClient.call('default', crypto.randomUUID());

await oliverCall.create({
ring: true,
data: {
members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
},
});

await expect(sachaRing).resolves.toHaveProperty(
'call.cid',
oliverCall.cid,
);

const sachaCall = await expectCall(sachaClient, oliverCall.cid);
expect(sachaCall).toBeDefined();
expect(oliverCall.state.callingState).toBe(CallingState.RINGING);
expect(sachaCall.state.callingState).toBe(CallingState.RINGING);

return { oliverCall, sachaCall };
};

it('should reject the call when the caller is busy', async () => {
[oliverClient, sachaClient, marceloClient] = await createClients(
testUserIds,
{
rejectCallWhenBusy: true,
},
);

//initiate a call between oliver and sacha
await setupInitialCall();

//marcelo is calling oliver (the caller). call should be automatically rejected
const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected');

const marceloCall = marceloClient.call('default', crypto.randomUUID());
await marceloCall.create({
ring: true,
data: {
members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
},
});

await expect(marceloIndividualRing).resolves.toHaveProperty(
'call.cid',
marceloCall.cid,
);
expect(oliverClient.state.calls.length).toBe(1);

await disconnectClients();
});

it('should reject the call when the callee is busy', async () => {
[oliverClient, sachaClient, marceloClient] = await createClients(
testUserIds,
{
rejectCallWhenBusy: true,
},
);

//initiate a call between oliver and sacha
await setupInitialCall();

//marcelo is calling sacha (the callee). call should be automatically rejected
const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected');

const marceloCall = marceloClient.call('default', crypto.randomUUID());
await marceloCall.create({
ring: true,
data: {
members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
},
});

await expect(marceloIndividualRing).resolves.toHaveProperty(
'call.cid',
marceloCall.cid,
);
expect(sachaClient.state.calls.length).toBe(1);

await disconnectClients();
});

it('should allow multiple simultaneous calls to the same caller when rejectCallWhenBusy is false', async () => {
//initiate a call between oliver and sacha
await setupInitialCall();

//marcelo is calling oliver (the caller)
const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected');

const marceloCall = marceloClient.call('default', crypto.randomUUID());
await marceloCall.create({
ring: true,
data: {
members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
},
});

//call should not be automatically rejected, exception will be thrown by timeout
await expect(marceloIndividualRing).rejects.toThrow();
//the caller has 2 calls available
expect(oliverClient.state.calls.length).toBe(2);
});

it('should allow multiple simultaneous calls to the same callee when rejectCallWhenBusy is false', async () => {
//initiate a call between oliver and sacha
await setupInitialCall();

//marcelo is calling sacha (the callee)
const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected');

const marceloCall = marceloClient.call('default', crypto.randomUUID());
await marceloCall.create({
ring: true,
data: {
members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
},
});

//call should not be automatically rejected, exception will be thrown by timeout
await expect(marceloIndividualRing).rejects.toThrow();
//the callee has 2 calls available
expect(sachaClient.state.calls.length).toBe(2);
});
});
});
64 changes: 26 additions & 38 deletions packages/client/src/devices/CameraManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Call } from '../Call';
import { CameraDirection, CameraManagerState } from './CameraManagerState';
import { DeviceManager } from './DeviceManager';
import { getVideoDevices, getVideoStream } from './devices';
import { OwnCapability, VideoSettingsResponse } from '../gen/coordinator';
import { VideoSettingsResponse } from '../gen/coordinator';
import { TrackType } from '../gen/video/sfu/models/models';
import { isMobile } from '../helpers/compatibility';
import { isReactNative } from '../helpers/platforms';
Expand Down Expand Up @@ -79,8 +79,15 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
* @internal
*/
async selectTargetResolution(resolution: { width: number; height: number }) {
this.targetResolution.height = resolution.height;
this.targetResolution.width = resolution.width;
// normalize target resolution to landscape format.
// on mobile devices, the device itself adjusts the resolution to portrait or landscape
// depending on the orientation of the device. using portrait resolution
// will result in falling back to the default resolution (640x480).
let { width, height } = resolution;
if (width < height) [width, height] = [height, width];
this.targetResolution.height = height;
this.targetResolution.width = width;

if (this.state.optimisticStatus === 'enabled') {
try {
await this.statusChangeSettled();
Expand All @@ -92,11 +99,8 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
if (this.enabled && this.state.mediaStream) {
const [videoTrack] = this.state.mediaStream.getVideoTracks();
if (!videoTrack) return;
const { width, height } = videoTrack.getSettings();
if (
width !== this.targetResolution.width ||
height !== this.targetResolution.height
) {
const { width: w, height: h } = videoTrack.getSettings();
if (w !== width || h !== height) {
await this.applySettingsToStream();
this.logger.debug(
`${width}x${height} target resolution applied to media stream`,
Expand All @@ -112,43 +116,27 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
* @param publish whether to publish the stream after applying the settings.
*/
async apply(settings: VideoSettingsResponse, publish: boolean) {
const hasPublishedVideo = !!this.call.state.localParticipant?.videoStream;
const hasPermission = this.call.permissionsContext.hasPermission(
OwnCapability.SEND_AUDIO,
);
if (hasPublishedVideo || !hasPermission) return;

// Wait for any in progress camera operation
await this.statusChangeSettled();
await this.selectTargetResolution(settings.target_resolution);

// apply a direction and enable the camera only if in "pristine" state
// and server defaults are not deferred to application code
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
if (this.state.status === undefined && !this.deferServerDefaults) {
if (!this.state.direction && !this.state.selectedDevice) {
const direction = settings.camera_facing === 'front' ? 'front' : 'back';
await this.selectDirection(direction);
}

const { target_resolution, camera_facing, camera_default_on, enabled } =
settings;
// normalize target resolution to landscape format.
// on mobile devices, the device itself adjusts the resolution to portrait or landscape
// depending on the orientation of the device. using portrait resolution
// will result in falling back to the default resolution (640x480).
let { width, height } = target_resolution;
if (width < height) [width, height] = [height, width];
await this.selectTargetResolution({ width, height });

// Set camera direction if it's not yet set
if (!this.state.direction && !this.state.selectedDevice) {
this.state.setDirection(camera_facing === 'front' ? 'front' : 'back');
if (canPublish && settings.camera_default_on && settings.enabled) {
await this.enable();
}
}

if (!publish) return;

const { mediaStream } = this.state;
if (this.enabled && mediaStream) {
// The camera is already enabled (e.g. lobby screen). Publish the stream
if (canPublish && publish && this.enabled && mediaStream) {
await this.publishStream(mediaStream);
} else if (
this.state.status === undefined &&
camera_default_on &&
enabled
) {
// Start camera if backend config specifies, and there is no local setting
await this.enable();
}
}

Expand Down
Loading
Loading