Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow reusing call instance [WIP] #1039

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -176,18 +176,50 @@ Follow our [Playing Video and Audio guide](../../guides/playing-video-and-audio/

#### Lobby preview

This is how you can show a visual representation about the sound level coming from the user's selected microphone:
On lobby screens a common UX pattern is to show a visual indicator of the detected audio levels coming from the selected microphone. The client exposes the `createSoundDetector` utility method to help implement this functionality. Here is an example of how you can do that:

```html
<progress id="volume" max="100" min="0"></progress>
```

```typescript
// Code example coming soon 🏗️
import { createSoundDetector } from '@stream-io/video-client';

let cleanup: Function | undefined;
call.microphone.state.mediaStream$.subscribe(async (mediaStream) => {
const progressBar = document.getElementById('volume') as HTMLProgressElement;
if (mediaStream) {
cleanup = createSoundDetector(
mediaStream,
(event) => {
progressBar.value = event.audioLevel;
},
{ detectionFrequencyInMs: 100 },
);
} else {
await cleanup?.();
progressBar.value = 0;
}
});
```

### Speaking while muted notification

When the microphone is disabled, the client will automatically start monitoring audio levels, to detect if the user is speaking.

This is how you can subscribe to these notifications:

```typescript
// This feature is coming soon 🏗️
call.microphone.state.speakingWhileMuted; // current value
call.microphone.state.speakingWhileMuted$.subscribe((isSpeaking) => {
if (isSpeaking) {
console.log(`You're muted, unmute yourself to speak`);
}
}); // Reactive value
```

The notification is automatically disabled if the user doesn't have the permission to send audio.

## Speaker management

### Browser support
Expand Down
129 changes: 104 additions & 25 deletions packages/client/src/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ export class Call {
this.streamClient = streamClient;
this.clientStore = clientStore;
this.streamClientBasePath = `/call/${this.type}/${this.id}`;
this.camera = new CameraManager(this);
this.microphone = new MicrophoneManager(this);
this.logger = getLogger(['Call']);

const callTypeConfig = CallTypes.get(type);
Expand Down Expand Up @@ -266,12 +268,42 @@ export class Call {
(subscriptions) => this.sfuClient?.updateSubscriptions(subscriptions),
),
);

this.camera = new CameraManager(this);
this.microphone = new MicrophoneManager(this);
}

private registerEffects() {
this.leaveCallHooks.add(
createSubscription(this.state.localParticipant$, async (p) => {
// Mute via device manager
// If integrator doesn't use device manager, we mute using stopPublish
if (
!p?.publishedTracks.includes(TrackType.VIDEO) &&
this.publisher?.isPublishing(TrackType.VIDEO)
) {
this.logger(
'info',
`Local participant's video track is muted remotely`,
);
await this.camera.disable();
if (this.publisher.isPublishing(TrackType.VIDEO)) {
await this.stopPublish(TrackType.VIDEO);
}
}
if (
!p?.publishedTracks.includes(TrackType.AUDIO) &&
this.publisher?.isPublishing(TrackType.AUDIO)
) {
this.logger(
'info',
`Local participant's audio track is muted remotely`,
);
await this.microphone.disable();
if (this.publisher.isPublishing(TrackType.AUDIO)) {
await this.stopPublish(TrackType.AUDIO);
}
}
}),
);

this.leaveCallHooks.add(
// handles updating the permissions context when the settings change.
createSubscription(this.state.settings$, (settings) => {
Expand Down Expand Up @@ -300,10 +332,50 @@ export class Call {
const hasPermission = this.permissionsContext.hasPermission(
permission as OwnCapability,
);
if (!hasPermission && this.publisher.isPublishing(trackType)) {
this.stopPublish(trackType).catch((err) => {
this.logger('error', `Error stopping publish ${trackType}`, err);
});
if (
!hasPermission &&
(this.publisher.isPublishing(trackType) ||
this.publisher.isLive(trackType))
) {
// Stop tracks, then notify device manager
this.stopPublish(trackType)
.catch((err) => {
this.logger(
'error',
`Error stopping publish ${trackType}`,
err,
);
})
.then(() => {
if (
trackType === TrackType.VIDEO &&
this.camera.state.status === 'enabled'
) {
this.camera
.disable()
.catch((err) =>
this.logger(
'error',
`Error disabling camera after pemission revoked`,
err,
),
);
}
if (
trackType === TrackType.AUDIO &&
this.microphone.state.status === 'enabled'
) {
this.microphone
.disable()
.catch((err) =>
this.logger(
'error',
`Error disabling microphone after pemission revoked`,
err,
),
);
}
});
}
}
}),
Expand Down Expand Up @@ -406,9 +478,9 @@ export class Call {
*/
leave = async ({ reject = false }: CallLeaveOptions = {}) => {
const callingState = this.state.callingState;
if (callingState === CallingState.LEFT) {
throw new Error('Cannot leave call that has already been left.');
}
// if (callingState === CallingState.LEFT) {
// throw new Error('Cannot leave call that has already been left.');
// }

if (callingState === CallingState.JOINING) {
await this.assertCallJoined();
Expand All @@ -433,18 +505,21 @@ export class Call {
this.subscriber?.close();
this.subscriber = undefined;

await this.microphone.disable();
await this.camera.disable();

this.publisher?.close();
this.publisher = undefined;

this.sfuClient?.close();
this.sfuClient = undefined;

this.dispatcher.offAll();
// this.dispatcher.offAll();

// Call all leave call hooks, e.g. to clean up global event handlers
this.leaveCallHooks.forEach((hook) => hook());
// this.leaveCallHooks.forEach((hook) => hook());

this.clientStore.unregisterCall(this);
// this.clientStore.unregisterCall(this);
this.state.setCallingState(CallingState.LEFT);
};

Expand Down Expand Up @@ -496,7 +571,6 @@ export class Call {

if (this.streamClient._hasConnectionID()) {
this.watching = true;
this.clientStore.registerCall(this);
}

return response;
Expand All @@ -523,7 +597,6 @@ export class Call {

if (this.streamClient._hasConnectionID()) {
this.watching = true;
this.clientStore.registerCall(this);
}

return response;
Expand Down Expand Up @@ -595,11 +668,11 @@ export class Call {
throw new Error(`Illegal State: Already joined.`);
}

if (callingState === CallingState.LEFT) {
throw new Error(
'Illegal State: Cannot join already left call. Create a new Call instance to join a call.',
);
}
// if (callingState === CallingState.LEFT) {
// throw new Error(
// 'Illegal State: Cannot join already left call. Create a new Call instance to join a call.',
// );
// }

const isMigrating = callingState === CallingState.MIGRATING;
this.state.setCallingState(CallingState.JOINING);
Expand Down Expand Up @@ -628,7 +701,6 @@ export class Call {

if (this.streamClient._hasConnectionID()) {
this.watching = true;
this.clientStore.registerCall(this);
}
} catch (error) {
// restore the previous call state if the join-flow fails
Expand Down Expand Up @@ -940,7 +1012,11 @@ export class Call {
await this.initCamera();
await this.initMic();
} catch (error) {
this.logger('warn', 'Camera and/or mic init failed during join call');
this.logger(
'warn',
'Camera and/or mic init failed during join call',
error,
);
}
}

Expand Down Expand Up @@ -1109,7 +1185,10 @@ export class Call {
* @param stopTrack if `true` the track will be stopped, else it will be just disabled
*/
stopPublish = async (trackType: TrackType, stopTrack: boolean = true) => {
this.logger('info', `stopPublish ${TrackType[trackType]}`);
this.logger(
'info',
`stopPublish ${TrackType[trackType]}, stop tracks: ${stopTrack}`,
);
await this.publisher?.unpublishStream(trackType, stopTrack);
};

Expand Down Expand Up @@ -1819,10 +1898,10 @@ export class Call {
this.microphone.state.mediaStream &&
!this.publisher?.isPublishing(TrackType.AUDIO)
) {
this.publishAudioStream(this.microphone.state.mediaStream);
await this.publishAudioStream(this.microphone.state.mediaStream);
}

// Start mic if backend config speicifies, and there is no local setting
// Start mic if backend config specifies, and there is no local setting
if (
this.microphone.state.status === undefined &&
this.state.settings?.audio.mic_default_on
Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/StreamSfuClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,5 +396,9 @@ const retryable = async <I extends object, O extends SfuResponseWithError>(
retryAttempt < MAX_RETRIES
);

if (rpcCallResult.response.error) {
throw rpcCallResult.response.error;
}

return rpcCallResult;
};
32 changes: 20 additions & 12 deletions packages/client/src/StreamVideoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export class StreamVideoClient {
const { call, members } = event;
if (user.id === call.created_by.id) {
this.logger(
'warn',
'debug',
'Received `call.created` sent by the current user',
);
return;
Expand Down Expand Up @@ -236,20 +236,23 @@ export class StreamVideoClient {
// if `call.created` was received before `call.ring`.
// In that case, we cleanup the already tracked call.
const prevCall = this.writeableStateStore.findCall(call.type, call.id);
await prevCall?.leave();
if (prevCall) {
await prevCall.leave();
this.writeableStateStore.unregisterCall(prevCall);
}
// we create a new call
const theCall = new Call({
const incomingCall = new Call({
streamClient: this.streamClient,
type: call.type,
id: call.id,
members,
clientStore: this.writeableStateStore,
ringing: true,
});
theCall.state.updateFromCallResponse(call);
incomingCall.state.updateFromCallResponse(call);
// we fetch the latest metadata for the call from the server
await theCall.get();
this.writeableStateStore.registerCall(theCall);
await incomingCall.get();
this.writeableStateStore.registerCall(incomingCall);
}),
);

Expand Down Expand Up @@ -311,12 +314,17 @@ export class StreamVideoClient {
* @param id the id of the call, if not provided a unique random value is used
*/
call = (type: string, id: string) => {
return new Call({
streamClient: this.streamClient,
id: id,
type: type,
clientStore: this.writeableStateStore,
});
return (
this.writeableStateStore.findCall(type, id) ??
this.writeableStateStore.registerCall(
new Call({
type: type,
id: id,
streamClient: this.streamClient,
clientStore: this.writeableStateStore,
}),
)
);
};

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/coordinator/connection/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const HINT_URL = `https://hint.stream-io-video.com/`;

export const getLocationHint = async (
hintUrl: string = HINT_URL,
timeout: number = 1500,
timeout: number = 2000,
) => {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout);
Expand All @@ -18,7 +18,7 @@ export const getLocationHint = async (
logger('debug', `Location header: ${awsPop}`);
return awsPop.substring(0, 3); // AMS1-P2 -> AMS
} catch (e) {
logger('error', `Failed to get location hint from ${HINT_URL}`, e);
logger('warn', `Failed to get location hint from ${HINT_URL}`, e);
return 'ERR';
} finally {
clearTimeout(timeoutId);
Expand Down
Loading
Loading