diff --git a/.gitignore b/.gitignore index 8e5a05ac5d..f35e8b1fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,6 @@ coverage /**/docusaurus/shared -lefthook.yml - # Test files video-buddy-session.json video-buddy-log.txt @@ -36,3 +34,35 @@ video-buddy-log.txt **/fastlane/*.mp4 **/fastlane/*.json + +# Xcode +# +**/ios/build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IJ +# +**/android/build/ +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml \ No newline at end of file diff --git a/package.json b/package.json index 5e7e06f913..a765875328 100644 --- a/package.json +++ b/package.json @@ -65,24 +65,23 @@ "@commitlint/config-angular": "^17.0.0", "@jscutlery/semver": "^2.30.1", "@nrwl/devkit": "^16.0.1", - "@types/eslint": "^8.44.4", - "@typescript-eslint/eslint-plugin": "^6.8.0", - "@typescript-eslint/parser": "^6.8.0", - "@typescript-eslint/typescript-estree": "^6.8.0", - "eslint": "^8.51.0", + "@types/eslint": "^8.56.10", + "@typescript-eslint/eslint-plugin": "^7.13.1", + "@typescript-eslint/parser": "^7.13.1", + "@typescript-eslint/typescript-estree": "^7.13.1", + "eslint": "^8.57.0", "eslint-config-react-app": "^7.0.1", "eslint-import-resolver-typescript": "^3.6.1", - "eslint-plugin-import": "^2.28.1", + "eslint-plugin-import": "^2.29.1", "eslint-plugin-prettier": "^5.1.3", "ngx-deploy-npm": "^5.2.0", "nx": "16.0.1", - "prettier": "^3.3.0", - "typescript": "^5.4.3", - "vercel": "^34.1.2", + "prettier": "^3.3.2", + "typescript": "^5.5.2", + "vercel": "^34.2.7", "vite": "^4.4.11" }, "resolutions": { - "eslint-plugin-prettier": "^5.1.3", - "prettier": "^3.3.0" + "eslint-plugin-prettier": "^5.1.3" } } diff --git a/packages/audio-filters-web/package.json b/packages/audio-filters-web/package.json index 6c4c3db75a..8dda57553a 100644 --- a/packages/audio-filters-web/package.json +++ b/packages/audio-filters-web/package.json @@ -31,8 +31,8 @@ "devDependencies": { "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-typescript": "^11.1.6", - "rimraf": "^5.0.5", + "rimraf": "^5.0.7", "rollup": "^3.29.4", - "typescript": "^5.4.3" + "typescript": "^5.5.2" } } diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index bf778e6e1a..d7b79a9c47 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -2,6 +2,20 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +### [1.4.3](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.4.2...@stream-io/video-client-1.4.3) (2024-06-25) + + +### Bug Fixes + +* improve browser permission handling ([#1394](https://github.com/GetStream/stream-video-js/issues/1394)) ([c8ccb21](https://github.com/GetStream/stream-video-js/commit/c8ccb219a43464d1215987d99fd01d8b4a407eb5)) + +### [1.4.2](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.4.1...@stream-io/video-client-1.4.2) (2024-06-24) + + +### Bug Fixes + +* support for portrait mode recording ([#1418](https://github.com/GetStream/stream-video-js/issues/1418)) ([70a304d](https://github.com/GetStream/stream-video-js/commit/70a304d3f20d93ecfffc97794e8e4974acf88e9a)) + ### [1.4.1](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.4.0...@stream-io/video-client-1.4.1) (2024-06-19) diff --git a/packages/client/docusaurus/docs/javascript/10-advanced/11-session-timers.mdx b/packages/client/docusaurus/docs/javascript/10-advanced/11-session-timers.mdx new file mode 100644 index 0000000000..5204766d59 --- /dev/null +++ b/packages/client/docusaurus/docs/javascript/10-advanced/11-session-timers.mdx @@ -0,0 +1,64 @@ +--- +id: session-timers +title: Session Timers +--- + +A session timer allows you to limit the maximum duration of a call. The duration +[can be configured](https://getstream.io/video/docs/api/calls/#session-timers) +for all calls of a certain type, or on a per-call basis. When a session timer +reaches zero, the call automatically ends. + +## Creating a call with a session timer + +Let's see how to create a single call with a limited duration: + +```ts +const callType = 'default'; +const callId = 'test-call'; + +const call = client.call(callType, callId); +await call.getOrCreate({ + data: { + settings_override: { + limits: { + max_duration_seconds: 3600, + }, + }, + }, +}); +``` + +This code creates a call with a duration of 3600 seconds (1 hour) from the time +the session is starts (a participant joins the call). + +After joining the call with the specified `max_duration_seconds`, you can +examine a session's `timer_ends_at` field, which provides the timestamp when the +call will end. When a call ends, all participants are removed from the call. + +```ts +await call.join(); +console.log(call.state.session?.timer_ends_at); +``` + +## Extending a call + +​You can also extend the duration of a call, both before or during the call. To +do that, you should use the `call.update` method: + +```ts +await call.get(); +// extend by 1 minute +const duration = call.state.settings?.limits.max_duration_seconds + 60; + +await call.update({ + settings_override: { + limits: { + max_duration_seconds: duration, + }, + }, +}); +``` + +If the call duration is extended, the `timer_ends_at` is updated to reflect this +change. Call participants will receive the `call.updated` event to notify them +about this change. diff --git a/packages/client/package.json b/packages/client/package.json index 5e0cfdf0be..1f7574ed0d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-client", - "version": "1.4.1", + "version": "1.4.3", "packageManager": "yarn@3.2.4", "main": "dist/index.cjs.js", "module": "dist/index.es.js", @@ -52,10 +52,10 @@ "@vitest/coverage-v8": "^0.34.4", "dotenv": "^16.3.1", "happy-dom": "^11.0.2", - "prettier": "^3.3.0", - "rimraf": "^5.0.5", + "prettier": "^3.3.2", + "rimraf": "^5.0.7", "rollup": "^3.29.4", - "typescript": "^5.4.3", + "typescript": "^5.5.2", "vite": "^4.4.11", "vitest": "^0.34.4", "vitest-mock-extended": "^1.2.1" diff --git a/packages/client/src/devices/BrowserPermission.ts b/packages/client/src/devices/BrowserPermission.ts new file mode 100644 index 0000000000..6a49b87e3d --- /dev/null +++ b/packages/client/src/devices/BrowserPermission.ts @@ -0,0 +1,152 @@ +import { fromEventPattern, map } from 'rxjs'; +import { isReactNative } from '../helpers/platforms'; +import { getLogger } from '../logger'; +import { disposeOfMediaStream } from './devices'; +import { withoutConcurrency } from '../helpers/concurrency'; + +interface BrowserPermissionConfig { + constraints: DisplayMediaStreamOptions; + queryName: PermissionName; +} + +export class BrowserPermission { + private ready: Promise; + private disposeController = new AbortController(); + private state: PermissionState | undefined; + private wasPrompted: boolean = false; + private listeners = new Set<(state: PermissionState) => void>(); + private logger = getLogger(['permissions']); + + constructor(private readonly permission: BrowserPermissionConfig) { + const signal = this.disposeController.signal; + + this.ready = (async () => { + const assumeGranted = (error?: unknown) => { + this.logger('warn', "Can't query permissions, assuming granted", { + permission, + error, + }); + this.setState('granted'); + }; + + if (!canQueryPermissions()) { + return assumeGranted(); + } + + try { + const status = await navigator.permissions.query({ + name: permission.queryName, + }); + + if (!signal.aborted) { + this.setState(status.state); + status.addEventListener('change', () => this.setState(status.state), { + signal, + }); + } + } catch (err) { + assumeGranted(err); + } + })(); + } + + dispose() { + this.state = undefined; + this.disposeController.abort(); + } + + async getState() { + await this.ready; + if (!this.state) { + throw new Error('BrowserPermission instance possibly disposed'); + } + return this.state; + } + + async prompt({ + forcePrompt = false, + throwOnNotAllowed = false, + }: { forcePrompt?: boolean; throwOnNotAllowed?: boolean } = {}) { + await withoutConcurrency( + `permission-prompt-${this.permission.queryName}`, + async () => { + if ( + (await this.getState()) !== 'prompt' || + (this.wasPrompted && !forcePrompt) + ) { + const isGranted = this.state === 'granted'; + + if (!isGranted && throwOnNotAllowed) { + throw new DOMException( + 'Permission was not granted previously, and prompting again is not allowed', + 'NotAllowedError', + ); + } + + return isGranted; + } + + try { + this.wasPrompted = true; + const stream = await navigator.mediaDevices.getUserMedia( + this.permission.constraints, + ); + disposeOfMediaStream(stream); + return true; + } catch (e) { + if (e instanceof DOMException && e.name === 'NotAllowedError') { + this.logger('info', 'Browser permission was not granted', { + permission: this.permission, + }); + + if (throwOnNotAllowed) { + throw e; + } + + return false; + } + + this.logger('error', `Failed to getUserMedia`, { + error: e, + permission: this.permission, + }); + throw e; + } + }, + ); + } + + listen(cb: (state: PermissionState) => void) { + this.listeners.add(cb); + if (this.state) cb(this.state); + return () => this.listeners.delete(cb); + } + + asObservable() { + return fromEventPattern( + (handler) => this.listen(handler), + (handler, unlisten) => unlisten(), + ).pipe( + // In some browsers, the 'change' event doesn't reliably emit and hence, + // permissionState stays in 'prompt' state forever. + // Typically, this happens when a user grants one-time permission. + // Instead of checking if a permission is granted, we check if it isn't denied + map((state) => state !== 'denied'), + ); + } + + private setState(state: PermissionState) { + if (this.state !== state) { + this.state = state; + this.listeners.forEach((listener) => listener(state)); + } + } +} + +function canQueryPermissions() { + return ( + !isReactNative() && + typeof navigator !== 'undefined' && + !!navigator.permissions?.query + ); +} diff --git a/packages/client/src/devices/CameraManagerState.ts b/packages/client/src/devices/CameraManagerState.ts index 5a70b1e5fd..b06f39e49e 100644 --- a/packages/client/src/devices/CameraManagerState.ts +++ b/packages/client/src/devices/CameraManagerState.ts @@ -1,6 +1,7 @@ import { BehaviorSubject, distinctUntilChanged, Observable } from 'rxjs'; import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState'; import { isReactNative } from '../helpers/platforms'; +import { getVideoBrowserPermission } from './devices'; export type CameraDirection = 'front' | 'back' | undefined; @@ -15,12 +16,7 @@ export class CameraManagerState extends InputMediaDeviceManagerState { direction$: Observable; constructor() { - super( - 'stop-tracks', - // `camera` is not in the W3C standard yet, - // but it's supported by Chrome and Safari. - 'camera' as PermissionName, - ); + super('stop-tracks', getVideoBrowserPermission()); this.direction$ = this.directionSubject .asObservable() .pipe(distinctUntilChanged()); diff --git a/packages/client/src/devices/InputMediaDeviceManagerState.ts b/packages/client/src/devices/InputMediaDeviceManagerState.ts index 8a4a17980c..92ac9a0ba0 100644 --- a/packages/client/src/devices/InputMediaDeviceManagerState.ts +++ b/packages/client/src/devices/InputMediaDeviceManagerState.ts @@ -2,11 +2,11 @@ import { BehaviorSubject, distinctUntilChanged, Observable, + of, shareReplay, } from 'rxjs'; -import { isReactNative } from '../helpers/platforms'; import { RxUtils } from '../store'; -import { getLogger } from '../logger'; +import { BrowserPermission } from './BrowserPermission'; export type InputDeviceStatus = 'enabled' | 'disabled' | undefined; export type TrackDisableMode = 'stop-tracks' | 'disable-tracks'; @@ -65,57 +65,23 @@ export abstract class InputMediaDeviceManagerState { * An observable that will emit `true` if browser/system permission * is granted, `false` otherwise. */ - hasBrowserPermission$ = new Observable((subscriber) => { - const notifyGranted = () => subscriber.next(true); - const permissionsAPIAvailable = !!navigator?.permissions?.query; - if (isReactNative() || !this.permissionName || !permissionsAPIAvailable) { - getLogger(['devices'])( - 'warn', - `Permissions can't be queried. Assuming granted.`, - ); - return notifyGranted(); - } - - let permissionState: PermissionStatus; - const notify = () => { - subscriber.next( - // In some browsers, the 'change' event doesn't reliably emit and hence, - // permissionState stays in 'prompt' state forever. - // Typically, this happens when a user grants one-time permission. - // Instead of checking if a permission is granted, we check if it isn't denied - permissionState.state !== 'denied', - ); - }; - navigator.permissions - .query({ name: this.permissionName }) - .then((permissionStatus) => { - permissionState = permissionStatus; - permissionState.addEventListener('change', notify); - notify(); - }) - .catch(() => { - // permission doesn't exist or can't be queried -> assume it's granted - // an example would be Firefox, - // where neither camera microphone permission can be queried - notifyGranted(); - }); - - return () => { - permissionState?.removeEventListener('change', notify); - }; - }).pipe(shareReplay(1)); + hasBrowserPermission$: Observable; /** * Constructs new InputMediaDeviceManagerState instance. * * @param disableMode the disable mode to use. - * @param permissionName the permission name to use for querying. + * @param permission the BrowserPermission to use for querying. * `undefined` means no permission is required. */ constructor( public readonly disableMode: TrackDisableMode = 'stop-tracks', - private readonly permissionName: PermissionName | undefined = undefined, - ) {} + permission?: BrowserPermission, + ) { + this.hasBrowserPermission$ = permission + ? permission.asObservable().pipe(shareReplay(1)) + : of(true); + } /** * The device status diff --git a/packages/client/src/devices/MicrophoneManagerState.ts b/packages/client/src/devices/MicrophoneManagerState.ts index 7f83c8decc..3204bb5869 100644 --- a/packages/client/src/devices/MicrophoneManagerState.ts +++ b/packages/client/src/devices/MicrophoneManagerState.ts @@ -3,6 +3,7 @@ import { InputMediaDeviceManagerState, TrackDisableMode, } from './InputMediaDeviceManagerState'; +import { getAudioBrowserPermission } from './devices'; export class MicrophoneManagerState extends InputMediaDeviceManagerState { private speakingWhileMutedSubject = new BehaviorSubject(false); @@ -15,12 +16,7 @@ export class MicrophoneManagerState extends InputMediaDeviceManagerState { speakingWhileMuted$: Observable; constructor(disableMode: TrackDisableMode) { - super( - disableMode, - // `microphone` is not in the W3C standard yet, - // but it's supported by Chrome and Safari. - 'microphone' as PermissionName, - ); + super(disableMode, getAudioBrowserPermission()); this.speakingWhileMuted$ = this.speakingWhileMutedSubject .asObservable() diff --git a/packages/client/src/devices/__tests__/CameraManager.test.ts b/packages/client/src/devices/__tests__/CameraManager.test.ts index 6748928bd8..8d8fb8d0e4 100644 --- a/packages/client/src/devices/__tests__/CameraManager.test.ts +++ b/packages/client/src/devices/__tests__/CameraManager.test.ts @@ -4,6 +4,7 @@ import { CallingState, StreamVideoWriteableStateStore } from '../../store'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { + mockBrowserPermission, mockCall, mockDeviceIds$, mockVideoDevices, @@ -22,6 +23,8 @@ vi.mock('../devices.ts', () => { return of(mockVideoDevices); }), getVideoStream: vi.fn(() => Promise.resolve(mockVideoStream())), + getAudioBrowserPermission: () => mockBrowserPermission, + getVideoBrowserPermission: () => mockBrowserPermission, deviceIds$: mockDeviceIds$(), }; }); diff --git a/packages/client/src/devices/__tests__/InputMediaDeviceManager.test.ts b/packages/client/src/devices/__tests__/InputMediaDeviceManager.test.ts index 444b304561..ae3b4743a7 100644 --- a/packages/client/src/devices/__tests__/InputMediaDeviceManager.test.ts +++ b/packages/client/src/devices/__tests__/InputMediaDeviceManager.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MockTrack, emitDeviceIds, + mockBrowserPermission, mockCall, mockDeviceIds$, mockVideoDevices, @@ -26,6 +27,8 @@ vi.mock('../../Call.ts', () => { vi.mock('../devices.ts', () => { console.log('MOCKING devices API'); return { + getAudioBrowserPermission: () => mockBrowserPermission, + getVideoBrowserPermission: () => mockBrowserPermission, deviceIds$: mockDeviceIds$(), }; }); @@ -48,7 +51,7 @@ class TestInputMediaDeviceManager extends InputMediaDeviceManager { await vi.runAllTimersAsync(); expect(manager.state.status).toBe('disabled'); - expect(manager.disablePromise).toBeUndefined(); - expect(manager.enablePromise).toBeUndefined(); + expect(manager.state.optimisticStatus).toBe('disabled'); expect(manager.state.selectedDevice).toBe(device.deviceId); vi.useRealTimers(); diff --git a/packages/client/src/devices/__tests__/InputMediaDeviceManagerFilters.test.ts b/packages/client/src/devices/__tests__/InputMediaDeviceManagerFilters.test.ts index fc612bd9e7..105e995608 100644 --- a/packages/client/src/devices/__tests__/InputMediaDeviceManagerFilters.test.ts +++ b/packages/client/src/devices/__tests__/InputMediaDeviceManagerFilters.test.ts @@ -5,7 +5,11 @@ import { StreamClient } from '../../coordinator/connection/client'; import { StreamVideoWriteableStateStore } from '../../store'; import { InputMediaDeviceManagerState } from '../InputMediaDeviceManagerState'; import { InputMediaDeviceManager } from '../InputMediaDeviceManager'; -import { mockVideoDevices, mockVideoStream } from './mocks'; +import { + mockBrowserPermission, + mockVideoDevices, + mockVideoStream, +} from './mocks'; import { TrackType } from '../../gen/video/sfu/models/models'; import '../../rtc/__tests__/mocks/webrtc.mocks'; @@ -28,7 +32,7 @@ class TestInputMediaDeviceManager extends InputMediaDeviceManager { - let state: InputMediaDeviceManagerState; - - beforeEach(() => { - state = new TestInputMediaDeviceManagerState(); - }); +function mockPermissionStatus(state: PermissionState): PermissionStatus { + return { + state, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any; +} +describe('InputMediaDeviceManagerState', () => { describe('hasBrowserPermission', () => { it('should emit true when permission is granted', async () => { - const permissionStatus: Partial = { - state: 'granted', - addEventListener: vi.fn(), - }; + const permissionStatus = mockPermissionStatus('granted'); const query = vi.fn(() => Promise.resolve(permissionStatus)); - globalThis.navigator ??= {} as Navigator; - // @ts-ignore - navigator is readonly, but we need to mock it - globalThis.navigator.permissions = { query }; + globalThis.navigator = { permissions: { query } } as any; + const state = new TestInputMediaDeviceManagerState(); + + const hasPermission = await firstValueFrom(state.hasBrowserPermission$); - const hasPermission = await new Promise((resolve) => { - state.hasBrowserPermission$.subscribe((v) => resolve(v)); - }); expect(hasPermission).toBe(true); expect(query).toHaveBeenCalledWith({ name: 'camera' }); expect(permissionStatus.addEventListener).toHaveBeenCalled(); }); it('should emit false when permission is denied', async () => { - const permissionStatus: Partial = { - state: 'denied', - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }; + const permissionStatus = mockPermissionStatus('denied'); const query = vi.fn(() => Promise.resolve(permissionStatus)); - globalThis.navigator ??= {} as Navigator; - // @ts-ignore - navigator is readonly, but we need to mock it - globalThis.navigator.permissions = { query }; + globalThis.navigator = { permissions: { query } } as any; + const state = new TestInputMediaDeviceManagerState(); + + const hasPermission = await firstValueFrom(state.hasBrowserPermission$); - const hasPermission = await new Promise((resolve) => { - state.hasBrowserPermission$.subscribe((v) => resolve(v)); - }); expect(hasPermission).toBe(false); expect(query).toHaveBeenCalledWith({ name: 'camera' }); expect(permissionStatus.addEventListener).toHaveBeenCalled(); }); it('should emit true when prompt is needed', async () => { - const permissionStatus: Partial = { - state: 'prompt', - addEventListener: vi.fn(), - }; + const permissionStatus = mockPermissionStatus('prompt'); const query = vi.fn(() => Promise.resolve(permissionStatus)); - globalThis.navigator ??= {} as Navigator; - // @ts-ignore - navigator is readonly, but we need to mock it - globalThis.navigator.permissions = { query }; + globalThis.navigator = { permissions: { query } } as any; + const state = new TestInputMediaDeviceManagerState(); + + const hasPermission = await firstValueFrom(state.hasBrowserPermission$); - const hasPermission = await new Promise((resolve) => { - state.hasBrowserPermission$.subscribe((v) => resolve(v)); - }); expect(hasPermission).toBe(true); expect(query).toHaveBeenCalledWith({ name: 'camera' }); expect(permissionStatus.addEventListener).toHaveBeenCalled(); @@ -74,25 +68,21 @@ describe('InputMediaDeviceManagerState', () => { it('should emit true when permissions cannot be queried', async () => { const query = vi.fn(() => Promise.reject()); - globalThis.navigator ??= {} as Navigator; - // @ts-ignore - navigator is readonly, but we need to mock it - globalThis.navigator.permissions = { query }; + globalThis.navigator = { permissions: { query } } as any; + const state = new TestInputMediaDeviceManagerState(); + + const hasPermission = await firstValueFrom(state.hasBrowserPermission$); - const hasPermission = await new Promise((resolve) => { - state.hasBrowserPermission$.subscribe((v) => resolve(v)); - }); expect(hasPermission).toBe(true); expect(query).toHaveBeenCalledWith({ name: 'camera' }); }); it('should emit true when permissions API is unavailable', async () => { - globalThis.navigator ??= {} as Navigator; - // @ts-ignore - navigator is readonly, but we need to mock it - globalThis.navigator.permissions = null; + globalThis.navigator = {} as any; + const state = new TestInputMediaDeviceManagerState(); + + const hasPermission = await firstValueFrom(state.hasBrowserPermission$); - const hasPermission = await new Promise((resolve) => { - state.hasBrowserPermission$.subscribe((v) => resolve(v)); - }); expect(hasPermission).toBe(true); }); }); diff --git a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts index 118421f45c..58f527f507 100644 --- a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts +++ b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts @@ -12,6 +12,7 @@ import { CallingState, StreamVideoWriteableStateStore } from '../../store'; import { mockAudioDevices, mockAudioStream, + mockBrowserPermission, mockCall, mockDeviceIds$, } from './mocks'; @@ -31,6 +32,8 @@ vi.mock('../devices.ts', () => { return of(mockAudioDevices); }), getAudioStream: vi.fn(() => Promise.resolve(mockAudioStream())), + getAudioBrowserPermission: () => mockBrowserPermission, + getVideoBrowserPermission: () => mockBrowserPermission, deviceIds$: mockDeviceIds$(), }; }); @@ -50,7 +53,7 @@ vi.mock('../../Call.ts', () => { }); class NoiseCancellationStub implements INoiseCancellation { - private listeners: { [event: string]: Array<() => void> } = {}; + private listeners: { [event: string]: Array<(arg: boolean) => void> } = {}; isSupported = () => true; init = () => Promise.resolve(undefined); diff --git a/packages/client/src/devices/__tests__/MicrophoneManagerRN.test.ts b/packages/client/src/devices/__tests__/MicrophoneManagerRN.test.ts index fb80582de3..a7e91afd84 100644 --- a/packages/client/src/devices/__tests__/MicrophoneManagerRN.test.ts +++ b/packages/client/src/devices/__tests__/MicrophoneManagerRN.test.ts @@ -4,7 +4,12 @@ import { MicrophoneManager } from '../MicrophoneManager'; import { Call } from '../../Call'; import { StreamClient } from '../../coordinator/connection/client'; import { StreamVideoWriteableStateStore } from '../../store'; -import { mockAudioDevices, mockAudioStream, mockCall } from './mocks'; +import { + mockAudioDevices, + mockAudioStream, + mockBrowserPermission, + mockCall, +} from './mocks'; import { of } from 'rxjs'; import '../../rtc/__tests__/mocks/webrtc.mocks'; import { OwnCapability } from '../../gen/coordinator'; @@ -25,6 +30,8 @@ vi.mock('../devices.ts', () => { return of(mockAudioDevices); }), getAudioStream: vi.fn(() => Promise.resolve(mockAudioStream())), + getAudioBrowserPermission: () => mockBrowserPermission, + getVideoBrowserPermission: () => mockBrowserPermission, deviceIds$: {}, }; }); diff --git a/packages/client/src/devices/__tests__/SpeakerManager.test.ts b/packages/client/src/devices/__tests__/SpeakerManager.test.ts index 2f53ad45b0..27f56a34bb 100644 --- a/packages/client/src/devices/__tests__/SpeakerManager.test.ts +++ b/packages/client/src/devices/__tests__/SpeakerManager.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { emitDeviceIds, mockAudioDevices, mockDeviceIds$ } from './mocks'; +import { + emitDeviceIds, + mockAudioDevices, + mockBrowserPermission, + mockDeviceIds$, +} from './mocks'; import { of } from 'rxjs'; import { SpeakerManager } from '../SpeakerManager'; import { checkIfAudioOutputChangeSupported } from '../devices'; @@ -12,6 +17,8 @@ vi.mock('../devices.ts', () => { return { getAudioOutputDevices: vi.fn(() => of(mockAudioDevices)), checkIfAudioOutputChangeSupported: vi.fn(() => true), + getAudioBrowserPermission: () => mockBrowserPermission, + getVideoBrowserPermission: () => mockBrowserPermission, deviceIds$: mockDeviceIds$(), }; }); diff --git a/packages/client/src/devices/__tests__/mocks.ts b/packages/client/src/devices/__tests__/mocks.ts index f02a2fcbe4..55a8c4bf23 100644 --- a/packages/client/src/devices/__tests__/mocks.ts +++ b/packages/client/src/devices/__tests__/mocks.ts @@ -5,7 +5,8 @@ import { OwnCapability, } from '../../gen/coordinator'; import { Call } from '../../Call'; -import { Subject } from 'rxjs'; +import { of, Subject } from 'rxjs'; +import { BrowserPermission } from '../BrowserPermission'; export const mockVideoDevices = [ { @@ -217,3 +218,7 @@ export const mockDeviceIds$ = () => { export const emitDeviceIds = (values: MediaDeviceInfo[]) => { deviceIds.next(values); }; + +export const mockBrowserPermission = { + asObservable: () => of(true), +} as BrowserPermission; diff --git a/packages/client/src/devices/devices.ts b/packages/client/src/devices/devices.ts index 040f87f952..846e1a8378 100644 --- a/packages/client/src/devices/devices.ts +++ b/packages/client/src/devices/devices.ts @@ -2,12 +2,15 @@ import { concatMap, debounceTime, from, + fromEvent, map, merge, - Observable, shareReplay, + startWith, } from 'rxjs'; import { getLogger } from '../logger'; +import { BrowserPermission } from './BrowserPermission'; +import { lazy } from '../helpers/lazy'; /** * Returns an Observable that emits the list of available devices @@ -16,49 +19,28 @@ import { getLogger } from '../logger'; * @param constraints the constraints to use when requesting the devices. * @param kind the kind of devices to enumerate. */ -const getDevices = ( - constraints: MediaStreamConstraints, - kind: MediaDeviceKind, -) => { - return new Observable((subscriber) => { - const enumerate = async () => { +const getDevices = (permission: BrowserPermission, kind: MediaDeviceKind) => { + return from( + (async () => { let devices = await navigator.mediaDevices.enumerateDevices(); - // some browsers report empty device labels (Firefox). - // in that case, we need to request permissions (via getUserMedia) - // to be able to get the device labels - const needsGetUserMedia = devices.some( + // for privacy reasons, most browsers don't give you device labels + // unless you have a corresponding camera or microphone permission + const shouldPromptForBrowserPermission = devices.some( (device) => device.kind === kind && device.label === '', ); - if (needsGetUserMedia) { - let mediaStream: MediaStream | undefined; - try { - mediaStream = await navigator.mediaDevices.getUserMedia(constraints); - devices = await navigator.mediaDevices.enumerateDevices(); - } finally { - if (mediaStream) disposeOfMediaStream(mediaStream); - } + if (shouldPromptForBrowserPermission) { + await permission.prompt({ throwOnNotAllowed: true }); + devices = await navigator.mediaDevices.enumerateDevices(); } - return devices; - }; - - enumerate() - .then((devices) => { - // notify subscribers and complete - subscriber.next(devices); - subscriber.complete(); - }) - .catch((error) => { - const logger = getLogger(['devices']); - logger('error', 'Failed to enumerate devices', error); - subscriber.error(error); - }); - }); + return devices.filter((d) => d.kind === kind); + })(), + ); }; /** - * [Tells if the browser supports audio output change on 'audio' elements](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId). - * - * */ + * Tells if the browser supports audio output change on 'audio' elements, + * see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId. + */ export const checkIfAudioOutputChangeSupported = () => { if (typeof document === 'undefined') return false; const element = document.createElement('audio'); @@ -87,97 +69,92 @@ const videoDeviceConstraints = { } satisfies MediaStreamConstraints; /** - * Creates a memoized observable instance - * that will be created only once and shared between all callers. - * - * @param create a function that creates an Observable. + * Keeps track of the browser permission to use microphone. This permission also + * affects an ability to enumerate audio devices. */ -const memoizedObservable = (create: () => Observable) => { - let memoized: Observable; - return () => { - if (!memoized) memoized = create(); - return memoized; - }; -}; +export const getAudioBrowserPermission = lazy( + () => + new BrowserPermission({ + constraints: audioDeviceConstraints, + queryName: 'microphone' as PermissionName, + }), +); -const getDeviceChangeObserver = memoizedObservable(() => { - // Audio and video devices are requested in two separate requests. - // That way, users will be presented with two separate prompts - // -> they can give access to just camera, or just microphone - return new Observable((subscriber) => { - // 'addEventListener' is not available in React Native - if (!navigator.mediaDevices.addEventListener) return; +/** + * Keeps track of the browser permission to use camera. This permission also + * affects an ability to enumerate video devices. + */ +export const getVideoBrowserPermission = lazy( + () => + new BrowserPermission({ + constraints: videoDeviceConstraints, + queryName: 'camera' as PermissionName, + }), +); - const notify = () => subscriber.next(); - navigator.mediaDevices.addEventListener('devicechange', notify); - return () => { - navigator.mediaDevices.removeEventListener('devicechange', notify); - }; - }).pipe( +const getDeviceChangeObserver = lazy(() => { + // 'addEventListener' is not available in React Native, returning + // an observable that will never fire + if (!navigator.mediaDevices.addEventListener) return from([]); + return fromEvent(navigator.mediaDevices, 'devicechange').pipe( + map(() => undefined), debounceTime(500), - concatMap(() => from(navigator.mediaDevices.enumerateDevices())), - shareReplay(1), ); }); -const getAudioDevicesObserver = memoizedObservable(() => { - return merge( - getDevices(audioDeviceConstraints, 'audioinput'), - getDeviceChangeObserver(), - ).pipe(shareReplay(1)); -}); - -const getAudioOutputDevicesObserver = memoizedObservable(() => { - return merge( - getDevices(audioDeviceConstraints, 'audiooutput'), - getDeviceChangeObserver(), - ).pipe(shareReplay(1)); -}); - -const getVideoDevicesObserver = memoizedObservable(() => { - return merge( - getDevices(videoDeviceConstraints, 'videoinput'), - getDeviceChangeObserver(), - ).pipe(shareReplay(1)); -}); - /** - * Prompts the user for a permission to use audio devices (if not already granted) and lists the available 'audioinput' devices, if devices are added/removed the list is updated. + * Prompts the user for a permission to use audio devices (if not already granted + * and was not prompted before) and lists the available 'audioinput' devices, + * if devices are added/removed the list is updated, and if the permission is revoked, + * the observable errors. */ -export const getAudioDevices = () => { - return getAudioDevicesObserver().pipe( - map((values) => values.filter((d) => d.kind === 'audioinput')), +export const getAudioDevices = lazy(() => { + return merge( + getDeviceChangeObserver(), + getAudioBrowserPermission().asObservable(), + ).pipe( + startWith(undefined), + concatMap(() => getDevices(getAudioBrowserPermission(), 'audioinput')), + shareReplay(1), ); -}; +}); /** - * Prompts the user for a permission to use video devices (if not already granted) and lists the available 'videoinput' devices, if devices are added/removed the list is updated. + * Prompts the user for a permission to use video devices (if not already granted + * and was not prompted before) and lists the available 'videoinput' devices, + * if devices are added/removed the list is updated, and if the permission is revoked, + * the observable errors. */ export const getVideoDevices = () => { - return getVideoDevicesObserver().pipe( - map((values) => values.filter((d) => d.kind === 'videoinput')), + return merge( + getDeviceChangeObserver(), + getVideoBrowserPermission().asObservable(), + ).pipe( + startWith(undefined), + concatMap(() => getDevices(getVideoBrowserPermission(), 'videoinput')), + shareReplay(1), ); }; /** - * Prompts the user for a permission to use audio devices (if not already granted) and lists the available 'audiooutput' devices, if devices are added/removed the list is updated. Selecting 'audiooutput' device only makes sense if [the browser has support for changing audio output on 'audio' elements](#checkifaudiooutputchangesupported) + * Prompts the user for a permission to use video devices (if not already granted + * and was not prompted before) and lists the available 'audiooutput' devices, + * if devices are added/removed the list is updated, and if the permission is revoked, + * the observable errors. */ export const getAudioOutputDevices = () => { - return getAudioOutputDevicesObserver().pipe( - map((values) => values.filter((d) => d.kind === 'audiooutput')), + return merge( + getDeviceChangeObserver(), + getAudioBrowserPermission().asObservable(), + ).pipe( + startWith(undefined), + concatMap(() => getDevices(getAudioBrowserPermission(), 'audiooutput')), + shareReplay(1), ); }; const getStream = async (constraints: MediaStreamConstraints) => { - try { - return await navigator.mediaDevices.getUserMedia(constraints); - } catch (e) { - getLogger(['devices'])('error', `Failed to getUserMedia`, { - error: e, - constraints: constraints, - }); - throw e; - } + return await navigator.mediaDevices.getUserMedia(constraints); }; /** @@ -197,7 +174,20 @@ export const getAudioStream = async ( ...trackConstraints, }, }; - return getStream(constraints); + + try { + await getAudioBrowserPermission().prompt({ + throwOnNotAllowed: true, + forcePrompt: true, + }); + return getStream(constraints); + } catch (e) { + getLogger(['devices'])('error', 'Failed to get audio stream', { + error: e, + constraints: constraints, + }); + throw e; + } }; /** @@ -217,7 +207,19 @@ export const getVideoStream = async ( ...trackConstraints, }, }; - return getStream(constraints); + try { + await getVideoBrowserPermission().prompt({ + throwOnNotAllowed: true, + forcePrompt: true, + }); + return getStream(constraints); + } catch (e) { + getLogger(['devices'])('error', 'Failed to get video stream', { + error: e, + constraints: constraints, + }); + throw e; + } }; /** @@ -257,12 +259,11 @@ export const getScreenShareStream = async ( export const deviceIds$ = typeof navigator !== 'undefined' && typeof navigator.mediaDevices !== 'undefined' - ? memoizedObservable(() => - merge( - from(navigator.mediaDevices.enumerateDevices()), - getDeviceChangeObserver(), - ).pipe(shareReplay(1)), - )() + ? getDeviceChangeObserver().pipe( + startWith(undefined), + concatMap(() => navigator.mediaDevices.enumerateDevices()), + shareReplay(1), + ) : undefined; /** diff --git a/packages/client/src/gen/coordinator/index.ts b/packages/client/src/gen/coordinator/index.ts index 6cce1a82b6..5cb51759ff 100644 --- a/packages/client/src/gen/coordinator/index.ts +++ b/packages/client/src/gen/coordinator/index.ts @@ -4985,6 +4985,11 @@ export const RecordSettingsRequestQualityEnum = { _720P: '720p', _1080P: '1080p', _1440P: '1440p', + PORTRAIT_360X640: 'portrait-360x640', + PORTRAIT_480X854: 'portrait-480x854', + PORTRAIT_720X1280: 'portrait-720x1280', + PORTRAIT_1080X1920: 'portrait-1080x1920', + PORTRAIT_1440X2560: 'portrait-1440x2560', } as const; export type RecordSettingsRequestQualityEnum = (typeof RecordSettingsRequestQualityEnum)[keyof typeof RecordSettingsRequestQualityEnum]; diff --git a/packages/client/src/helpers/RNSpeechDetector.ts b/packages/client/src/helpers/RNSpeechDetector.ts index 79ed52bdf2..4a59264c1a 100644 --- a/packages/client/src/helpers/RNSpeechDetector.ts +++ b/packages/client/src/helpers/RNSpeechDetector.ts @@ -7,7 +7,7 @@ const AUDIO_LEVEL_THRESHOLD = 0.2; export class RNSpeechDetector { private pc1 = new RTCPeerConnection({}); private pc2 = new RTCPeerConnection({}); - private intervalId: NodeJS.Timer | undefined; + private intervalId: NodeJS.Timeout | undefined; /** * Starts the speech detection. diff --git a/packages/client/src/helpers/lazy.ts b/packages/client/src/helpers/lazy.ts new file mode 100644 index 0000000000..208242f293 --- /dev/null +++ b/packages/client/src/helpers/lazy.ts @@ -0,0 +1,15 @@ +const uninitialized = Symbol('uninitialized'); + +/** + * Lazily creates a value using a provided factory + */ +export function lazy(factory: () => T): () => T { + let value: T | typeof uninitialized = uninitialized; + return () => { + if (value === uninitialized) { + value = factory(); + } + + return value; + }; +} diff --git a/packages/react-bindings/CHANGELOG.md b/packages/react-bindings/CHANGELOG.md index 7a4c68a097..116150395b 100644 --- a/packages/react-bindings/CHANGELOG.md +++ b/packages/react-bindings/CHANGELOG.md @@ -2,6 +2,16 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +### [0.4.47](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-bindings-0.4.46...@stream-io/video-react-bindings-0.4.47) (2024-06-25) + +### Dependency Updates + +* `@stream-io/video-client` updated to version `1.4.3` +### [0.4.46](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-bindings-0.4.45...@stream-io/video-react-bindings-0.4.46) (2024-06-24) + +### Dependency Updates + +* `@stream-io/video-client` updated to version `1.4.2` ### [0.4.45](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-bindings-0.4.44...@stream-io/video-react-bindings-0.4.45) (2024-06-19) ### Dependency Updates diff --git a/packages/react-bindings/package.json b/packages/react-bindings/package.json index 01bfa7bc3c..a6f24d689e 100644 --- a/packages/react-bindings/package.json +++ b/packages/react-bindings/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-react-bindings", - "version": "0.4.45", + "version": "0.4.47", "packageManager": "yarn@3.2.4", "main": "./dist/index.cjs.js", "module": "./dist/index.es.js", @@ -33,8 +33,8 @@ "@stream-io/video-client": "workspace:^", "@types/react": "^18.3.2", "react": "^18.3.1", - "rimraf": "^5.0.5", + "rimraf": "^5.0.7", "rollup": "^3.29.4", - "typescript": "^5.4.3" + "typescript": "^5.5.2" } } diff --git a/packages/react-native-sdk/.gitignore b/packages/react-native-sdk/.gitignore index 36ab9ccfa9..56c2db140a 100644 --- a/packages/react-native-sdk/.gitignore +++ b/packages/react-native-sdk/.gitignore @@ -65,3 +65,4 @@ buck-out/ /vendor/bundle/ version.ts +!docusaurus/.env diff --git a/packages/react-native-sdk/CHANGELOG.md b/packages/react-native-sdk/CHANGELOG.md index 3f84d145cd..383bb830bc 100644 --- a/packages/react-native-sdk/CHANGELOG.md +++ b/packages/react-native-sdk/CHANGELOG.md @@ -2,6 +2,18 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +### [0.8.7](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-0.8.6...@stream-io/video-react-native-sdk-0.8.7) (2024-06-25) + +### Dependency Updates + +* `@stream-io/video-client` updated to version `1.4.3` +* `@stream-io/video-react-bindings` updated to version `0.4.47` +### [0.8.6](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-0.8.5...@stream-io/video-react-native-sdk-0.8.6) (2024-06-24) + +### Dependency Updates + +* `@stream-io/video-client` updated to version `1.4.2` +* `@stream-io/video-react-bindings` updated to version `0.4.46` ### [0.8.5](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-0.8.4...@stream-io/video-react-native-sdk-0.8.5) (2024-06-19) ### Dependency Updates diff --git a/packages/react-native-sdk/docusaurus/.env b/packages/react-native-sdk/docusaurus/.env new file mode 100644 index 0000000000..e9f70f9cf4 --- /dev/null +++ b/packages/react-native-sdk/docusaurus/.env @@ -0,0 +1 @@ +PRODUCT=video diff --git a/packages/react-native-sdk/docusaurus/docs/reactnative/01-setup/01-introduction.mdx b/packages/react-native-sdk/docusaurus/docs/reactnative/01-setup/01-introduction.mdx index 560c986240..27f97fc2d4 100644 --- a/packages/react-native-sdk/docusaurus/docs/reactnative/01-setup/01-introduction.mdx +++ b/packages/react-native-sdk/docusaurus/docs/reactnative/01-setup/01-introduction.mdx @@ -10,9 +10,9 @@ Our React Native SDK comes with user-friendly UI components, easy-to-use React h If you're new to Stream React Native Video SDK, we recommend starting with the following three tutorials, depending on your requirements: -- [Video & Audio Calling Tutorial](https://getstream.io/video/sdk/reactnative/tutorial/video-calling/) -- [Audio Room Tutorial](https://getstream.io/video/sdk/reactnative/tutorial/audio-room/) -- [Livestream Tutorial](https://getstream.io/video/sdk/reactnative/tutorial/livestreaming/) +- [Video & Audio Calling Tutorial](https://getstream.io/video/sdk/react-native/tutorial/video-calling/) +- [Audio Room Tutorial](https://getstream.io/video/sdk/react-native/tutorial/audio-room/) +- [Livestream Tutorial](https://getstream.io/video/sdk/react-native/tutorial/livestreaming/) After the tutorials, the documentation explains how to use: diff --git a/packages/react-native-sdk/docusaurus/docs/reactnative/03-core/04-camera-and-microphone.mdx b/packages/react-native-sdk/docusaurus/docs/reactnative/03-core/04-camera-and-microphone.mdx index 1ed8a9fe60..ee6a6092a7 100644 --- a/packages/react-native-sdk/docusaurus/docs/reactnative/03-core/04-camera-and-microphone.mdx +++ b/packages/react-native-sdk/docusaurus/docs/reactnative/03-core/04-camera-and-microphone.mdx @@ -51,7 +51,7 @@ await camera.disable(); It's always best to await calls to `enable()`, `disable()`, and `toggle()`, however the SDK does its best to resolve potential race conditions: the last call always wins, so it's safe to make these calls in an event handler. -Status is updated once the camera is actually enabled or disabled. Use `isOptimisticMute` for the "optimistic" status that is updated immediately after toggling the camera. +Status is updated once the camera is actually enabled or disabled. Use `optimisticIsMute` for the "optimistic" status that is updated immediately after toggling the camera. ### Manage Camera Facing Mode @@ -159,7 +159,7 @@ await microphone.disable(); It's always best to await calls to `enable()`, `disable()`, and `toggle()`, however the SDK does its best to resolve potential race conditions: the last call always wins, so it's safe to make these calls in an event handler. -Status is updated once the microphone is actually enabled or disabled. Use `isOptimisticMute` for the "optimistic" status that is updated immediately after toggling the microphone. +Status is updated once the microphone is actually enabled or disabled. Use `optimisticIsMute` for the "optimistic" status that is updated immediately after toggling the microphone. ### Audio mute status diff --git a/packages/react-native-sdk/docusaurus/docs/reactnative/03-core/05-call-types.mdx b/packages/react-native-sdk/docusaurus/docs/reactnative/03-core/05-call-types.mdx index c3be708b92..418c87adfd 100644 --- a/packages/react-native-sdk/docusaurus/docs/reactnative/03-core/05-call-types.mdx +++ b/packages/react-native-sdk/docusaurus/docs/reactnative/03-core/05-call-types.mdx @@ -11,10 +11,10 @@ import WithExternalLinks from '../../../shared/video/_withExternalLinks'; diff --git a/packages/react-native-sdk/docusaurus/docs/reactnative/05-ui-cookbook/14-watching-a-livestream.mdx b/packages/react-native-sdk/docusaurus/docs/reactnative/05-ui-cookbook/14-watching-a-livestream.mdx index f8d2e21a76..802aa36823 100644 --- a/packages/react-native-sdk/docusaurus/docs/reactnative/05-ui-cookbook/14-watching-a-livestream.mdx +++ b/packages/react-native-sdk/docusaurus/docs/reactnative/05-ui-cookbook/14-watching-a-livestream.mdx @@ -9,7 +9,7 @@ import ViewerLivestreamScreenShare from '../assets/05-ui-cookbook/14-watching-a- The Video API allows you to assign specific roles for users in a livestream, such as hosts and viewers. Our SDK provides dedicated livestreaming components for both of these roles. -The `ViewerLivestream` component leverages the WebRTC protocol for seamless livestream viewing within the SDK. To enable external publishing, you can access HLS credentials from the dashboard. For additional information, please refer to our [livestream tutorial](https://getstream.io/video/sdk/reactnative/tutorial/livestreaming/). +The `ViewerLivestream` component leverages the WebRTC protocol for seamless livestream viewing within the SDK. To enable external publishing, you can access HLS credentials from the dashboard. For additional information, please refer to our [livestream tutorial](https://getstream.io/video/sdk/react-native/tutorial/livestreaming/). This guide describes how to customize watching a livestream through our SDK. diff --git a/packages/react-native-sdk/docusaurus/docs/reactnative/05-ui-cookbook/17-session-timers.mdx b/packages/react-native-sdk/docusaurus/docs/reactnative/05-ui-cookbook/17-session-timers.mdx new file mode 100644 index 0000000000..5711180e17 --- /dev/null +++ b/packages/react-native-sdk/docusaurus/docs/reactnative/05-ui-cookbook/17-session-timers.mdx @@ -0,0 +1,421 @@ +--- +id: session-timers +title: Session Timers +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +A session timer allows you to **limit the maximum duration** of a call. It's +possible to +[configure a session timer](https://getstream.io/video/docs/api/calls/#session-timers) +for a single call, or every call of a certain type. When a session timer reaches +zero, the call automatically ends, making it a great tool for managing paid +appointments. + +In this article, we'll integrate a session timer into a sample telemedicine +application. We assume that two users are joining a call: a medical specialist +and a patient. Each appointment lasts 1 hour, but the specialist can extend the +appointment if necessary. + +## Prerequisites + +Let's start by setting up an application. Here's what we need: + +1. Separate user roles for a medical specialist (`specialist`) and a patient + (`patient`) +2. An `appointment` call type with a maximum duration of 1 hour +3. Two test users, one for each call (we'll call them `dr-lecter` and `bill`) +4. One test call of an `appointment` type + +The quickest way to set up these requirements is to use the **server-side +Node.js SDK**. Let us create a one-off Node.js script. So let's install it in a +project: + + + + +```bash +yarn add @stream-io/node-sdk +``` + + + + +```bash +npm install @stream-io/node-sdk +``` + + + + +Then create a one-off Node.js script with the following: + +```ts title="script.ts" +import { StreamClient, VideoOwnCapability } from '@stream-io/node-sdk'; + +const apiKey = 'REPLACE_WITH_API_KEY'; +const secret = 'REPLACE_WITH_SECRET'; +const client = new StreamClient(apiKey, secret); + +async function main() { + // 1. Roles for a medical specialist (`specialist`) and a patient: + await client.createRole({ name: 'specialist' }); + await client.createRole({ name: 'patient' }); + + // 2. Call type with the maximum duration of 1 hour: + await client.video.createCallType({ + name: 'appointment', + grants: { + specialist: [ + VideoOwnCapability.JOIN_CALL, + VideoOwnCapability.SEND_AUDIO, + VideoOwnCapability.SEND_VIDEO, + // These capabilities are required to change session duration: + VideoOwnCapability.UPDATE_CALL, + VideoOwnCapability.UPDATE_CALL_SETTINGS, + ], + patient: [ + VideoOwnCapability.JOIN_CALL, + VideoOwnCapability.SEND_AUDIO, + VideoOwnCapability.SEND_VIDEO, + ], + }, + settings: { + limits: { + // 3600 seconds = 1 hour + max_duration_seconds: 3600, + }, + }, + }); + + // 3. Two test users: + await client.upsertUsers({ + users: { + 'dr-lecter': { + id: 'dr-lecter', + name: 'Dr. Hannibal Lecter', + role: 'specialist', + }, + 'bill': { + id: 'bill', + name: 'Buffalo Bill', + role: 'patient', + }, + }, + }); + + // 4. Test call: + await client.video.call('appointment', 'test-call').create({ + data: { + members: [{ user_id: 'dr-lecter' }, { user_id: 'bill' }], + created_by_id: 'dr-lecter', + }, + }); +} + +main(); +``` + +Now, run the script with the following: + +```bash + npx ts-node script.ts +``` + +We can verify that the script ran successfully by checking the `Call Types` and +the `Roles & Permissions` sections in the application +[dashboard](https://dashboard.getstream.io/). + +Now we're ready to add a session timer to our application. If you haven't +already bootstrapped a video calling application (our +[Video Calling Tutorial](https://getstream.io/video/sdk/react-native/tutorial/video-calling/) +is a great place to start!), here's a very simple application that we'll use as +a starting point: + +```tsx +import { + Call, + CallContent, + StreamCall, + StreamVideo, + StreamVideoClient, +} from '@stream-io/video-react-native-sdk'; +import React, { useState, useEffect } from 'react'; +import { ActivityIndicator, SafeAreaView, StyleSheet } from 'react-native'; + +const client = new StreamVideoClient({ + apiKey: 'REPLACE_WITH_API_KEY', + user: { + /* one of the test users */ + id: 'bill', + name: 'Buffalo Bill', + }, + token: 'REPLACE_WITH_USER_TOKEN', +}); + +const RootContainer = (props: React.PropsWithChildren<{}>) => { + return {props.children}; +}; + +const callId = 'test-call'; + +const App = () => { + const [call, setCall] = useState(); + + useEffect(() => { + const newCall = client.call('appointment', callId); + newCall + .join() + .then(() => setCall(newCall)) + .catch(() => console.error('Failed to join the call')); + + return () => { + newCall.leave().catch(() => console.error('Failed to leave the call')); + }; + }, []); + + if (!call) { + return ( + + + + ); + } + + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: 'black', + flex: 1, + justifyContent: 'center', + }, +}); + +export default App; +``` + +At this point it's also possible to override the default call duration. The user +must have a permission to update call and call settings (in our case, the +`specialist` role has these permissions): + +```js +newCall.join({ + data: { + settings_override: { + limits: { + max_duration_seconds: 7200, + }, + }, + }, +}); +``` + +## Session Timer Component + +After joining the call, we can examine the `session.timer_ends_at` property: if +the session timer has been set up, it contains the timestamp at which point the +call automatically ends. + +Let's implement a component that displays a countdown to the end of the session: + +```tsx +import { useCallStateHooks } from '@stream-io/video-react-native-sdk'; +import { StyleSheet, Text, View } from 'react-native'; + +const useSessionTimer = () => { + const { useCallSession } = useCallStateHooks(); + const session = useCallSession(); + const [remainingMs, setRemainingMs] = useState(Number.NaN); + + useEffect(() => { + if (!session?.timer_ends_at) { + return; + } + const timeEndAtMillis = new Date(session.timer_ends_at).getTime(); + const handle = setInterval(() => { + setRemainingMs(timeEndAtMillis - Date.now()); + }, 500); + return () => clearInterval(handle); + }, [session?.timer_ends_at]); + + return remainingMs; +}; + +function convertMillis(milliseconds: number) { + // Calculate the number of minutes and seconds + const minutes = Math.floor(milliseconds / 60000); + const seconds = ((milliseconds % 60000) / 1000).toFixed(0); + + // Format the output + return `${minutes} mins:${seconds.padStart(2, '0')} secs`; +} + +const SessionTimer = () => { + const remainingMs = useSessionTimer(); + return ( + + {convertMillis(remainingMs)} + + ); +}; + +const styles = StyleSheet.create({ + sessionTimer: { + top: 0, + left: 0, + right: 0, + backgroundColor: 'white', + }, + sessionTimerText: { + color: 'black', + fontSize: 24, + textAlign: 'center', + }, +}); +``` + +And now by adding this component inside of the `StreamCall`, we get a ticking +countdown in the call UI: + +```tsx + + + // highlight-next-line + + + + +``` + +![SessionTimer component in use](../assets/05-ui-cookbook/17-session-timers/session-timer.png) + +## Adding Alerts + +It's easy to lose track of time during a meeting and then be surprised when time +runs out. Let's add an alert that pops up on the page twenty minutes before the +session timer reaches zero: + +```tsx +import { Alert } from 'react-native'; + +const useSessionTimerAlert = (remainingMs: number, thresholdMs: number) => { + const didAlert = useRef(false); + + useEffect(() => { + if (!didAlert.current && remainingMs < thresholdMs) { + Alert.alert( + 'Notice', + `Less than ${thresholdMs / 60000} minutes remaining` + ); + didAlert.current = true; + } + }, [remainingMs, thresholdMs]); +}; + +const SessionTimer = () => { + const remainingMs = useSessionTimer(); + // highlight-next-line + useSessionTimerAlert(remainingMs, 20 * 60 * 1000); + return ( + + {convertMillis(remainingMs)} + + ); +}; +``` + +![Alert indicating that session is about to end](../assets/05-ui-cookbook/17-session-timers/session-timer-alert.png) + +Similarly, we can show an alert when the call session is over (when time has +elapsed): + +```tsx +const useSessionEndedAlert = (remainingMs: number) => { + const didAlert = useRef(false); + + useEffect(() => { + if (!didAlert.current && remainingMs <= 0) { + Alert.alert('Call ended'); + didAlert.current = true; + } + }, [remainingMs]); +}; +``` + +## Extending a Session + +The `specialist` user role that we created has the permission to update call +settings, granting it the `change-max-duration` capability, which allows a user +to change the duration of a call. Let's add a component that updates the call +settings and extends a session by the specified number of seconds: + +```jsx +import { + OwnCapability, + useCall, + useCallStateHooks, +} from '@stream-io/video-react-native-sdk'; +import { Button } from 'react-native'; + +const ExtendSessionButton = ({ + durationSecsToExtend, +}: { + durationSecsToExtend: number; +}) => { + const call = useCall(); + const { useCallSettings, useHasPermissions } = useCallStateHooks(); + const settings = useCallSettings(); + const canExtend = useHasPermissions(OwnCapability.CHANGE_MAX_DURATION); + + if (!canExtend) { + return null; + } + + const onPress = () => { + call?.update({ + settings_override: { + limits: { + max_duration_seconds: + (settings?.limits?.max_duration_seconds ?? 0) + + durationSecsToExtend, + }, + }, + }); + }; + + return ( +