diff --git a/.changeset/itchy-boxes-type.md b/.changeset/itchy-boxes-type.md new file mode 100644 index 000000000..57e5c5f79 --- /dev/null +++ b/.changeset/itchy-boxes-type.md @@ -0,0 +1,5 @@ +--- +'@signalwire/core': patch +--- + +[internal] Add ability to track the Authorization state diff --git a/.changeset/strong-lobsters-compete.md b/.changeset/strong-lobsters-compete.md new file mode 100644 index 000000000..a284f9e8a --- /dev/null +++ b/.changeset/strong-lobsters-compete.md @@ -0,0 +1,5 @@ +--- +'@signalwire/js': minor +--- + +[internal] Add RoomSession.joinAudience method diff --git a/.changeset/tiny-points-love.md b/.changeset/tiny-points-love.md new file mode 100644 index 000000000..97f83b1f5 --- /dev/null +++ b/.changeset/tiny-points-love.md @@ -0,0 +1,5 @@ +--- +'@signalwire/webrtc': patch +--- + +[internal] Add ability to update the media options diff --git a/internal/playground-js/src/heroku/index.js b/internal/playground-js/src/heroku/index.js index 67a5c6d73..76657238b 100644 --- a/internal/playground-js/src/heroku/index.js +++ b/internal/playground-js/src/heroku/index.js @@ -188,6 +188,10 @@ function meter(el, val) { } const initializeMicAnalyzer = async (stream) => { + if (!stream) { + return + } + const el = document.getElementById('mic-meter') micAnalyzer = await createMicrophoneAnalyzer(stream) micAnalyzer.on('volumeChanged', (vol) => { diff --git a/packages/core/src/BaseComponent.ts b/packages/core/src/BaseComponent.ts index 4ec6f59c0..2f0566fb5 100644 --- a/packages/core/src/BaseComponent.ts +++ b/packages/core/src/BaseComponent.ts @@ -21,6 +21,7 @@ import { SDKWorkerDefinition, SessionAuthStatus, SDKWorkerHooks, + Authorization, } from './utils/interfaces' import { EventEmitter } from './utils/EventEmitter' import { SDKState } from './redux/interfaces' @@ -32,6 +33,7 @@ import { } from './types' import { getAuthError, + getAuthState, getAuthStatus, } from './redux/features/session/sessionSelectors' import { compoundEventAttachAction } from './redux/actions' @@ -835,6 +837,11 @@ export class BaseComponent< return getAuthStatus(this.store.getState()) } + /** @internal */ + protected get _sessionAuthState(): Authorization | undefined { + return getAuthState(this.store.getState()) + } + /** @internal */ protected _waitUntilSessionAuthorized(): Promise { const authStatus = getAuthStatus(this.store.getState()) diff --git a/packages/core/src/redux/features/component/componentSaga.test.ts b/packages/core/src/redux/features/component/componentSaga.test.ts index 2fbd51557..b55856d48 100644 --- a/packages/core/src/redux/features/component/componentSaga.test.ts +++ b/packages/core/src/redux/features/component/componentSaga.test.ts @@ -43,6 +43,7 @@ describe('componentCleanupSaga', () => { protocol: '', iceServers: [], authStatus: 'unknown', + authState: undefined, authError: undefined, authCount: 0, }, diff --git a/packages/core/src/redux/features/session/sessionSelectors.ts b/packages/core/src/redux/features/session/sessionSelectors.ts index c153d1d26..f249cdf66 100644 --- a/packages/core/src/redux/features/session/sessionSelectors.ts +++ b/packages/core/src/redux/features/session/sessionSelectors.ts @@ -15,3 +15,7 @@ export const getAuthStatus = ({ session }: SDKState) => { export const getAuthError = ({ session }: SDKState) => { return session.authError } + +export const getAuthState = ({ session }: SDKState) => { + return session.authState +} diff --git a/packages/core/src/redux/features/session/sessionSlice.test.ts b/packages/core/src/redux/features/session/sessionSlice.test.ts index c9a5405e7..fff7d8c53 100644 --- a/packages/core/src/redux/features/session/sessionSlice.test.ts +++ b/packages/core/src/redux/features/session/sessionSlice.test.ts @@ -17,6 +17,22 @@ describe('SessionState Tests', () => { protocol: rpcConnectResultVRT.protocol, iceServers: rpcConnectResultVRT.ice_servers, authStatus: 'authorized', + authState: { + audio_allowed: true, + project: '8f0a119a-cda7-4497-a47d-c81493b824d4', + resource: '9c80f1e8-9430-4070-a043-937eb3a96b38', + room: { + name: 'lobby', + scopes: ['room.self.audio_mute', 'room.self.audio_unmute'], + }, + scope_id: '26675883-8499-4ee9-85eb-691c4aa209f8', + scopes: ['video'], + signature: + 'SGZtkRD9fvuBAOUp1UF56zESxdEvGT6qSGZtkRD9fvuBAOUp1UF56zESxdEvGT6q', + type: 'video', + user_name: 'Joe', + video_allowed: true, + }, authError: undefined, authCount: 1, }) diff --git a/packages/core/src/redux/features/session/sessionSlice.ts b/packages/core/src/redux/features/session/sessionSlice.ts index b111e9afe..85b115e9f 100644 --- a/packages/core/src/redux/features/session/sessionSlice.ts +++ b/packages/core/src/redux/features/session/sessionSlice.ts @@ -13,6 +13,7 @@ export const initialSessionState: DeepReadonly = { protocol: '', iceServers: [], authStatus: 'unknown', + authState: undefined, authError: undefined, authCount: 0, } @@ -25,6 +26,7 @@ const sessionSlice = createDestroyableSlice({ return { ...state, authStatus: 'authorized', + authState: payload?.authorization, authCount: state.authCount + 1, protocol: payload?.protocol ?? '', iceServers: payload?.ice_servers ?? [], diff --git a/packages/core/src/redux/interfaces.ts b/packages/core/src/redux/interfaces.ts index 74d4b2f73..0af3f6d93 100644 --- a/packages/core/src/redux/interfaces.ts +++ b/packages/core/src/redux/interfaces.ts @@ -7,6 +7,7 @@ import { SessionEvents, JSONRPCMethod, BaseConnectionState, + Authorization, } from '../utils/interfaces' import type { VideoAPIEventParams, @@ -62,6 +63,7 @@ export interface SessionState { protocol: string iceServers?: RTCIceServer[] authStatus: SessionAuthStatus + authState?: Authorization authError?: SessionAuthError authCount: number } diff --git a/packages/core/src/testUtils.ts b/packages/core/src/testUtils.ts index 63d387733..9a2fdff82 100644 --- a/packages/core/src/testUtils.ts +++ b/packages/core/src/testUtils.ts @@ -60,6 +60,8 @@ export const rpcConnectResultVRT: RPCConnectResult = { }, signature: 'SGZtkRD9fvuBAOUp1UF56zESxdEvGT6qSGZtkRD9fvuBAOUp1UF56zESxdEvGT6q', + audio_allowed: true, + video_allowed: true, }, protocol: 'signalwire_SGZtkRD9fvuBAOUp1UF56zESxdEvGT6qSGZtkRD9fvuBAOUp1UF56zESxdEvGT6q_03e8c927-8ea3-4661-86d5-778c3e03296a_8f0a119a-cda7-4497-a47d-c81493b824d4', diff --git a/packages/core/src/utils/interfaces.ts b/packages/core/src/utils/interfaces.ts index fdd7695e1..b29be07b8 100644 --- a/packages/core/src/utils/interfaces.ts +++ b/packages/core/src/utils/interfaces.ts @@ -158,7 +158,7 @@ export interface SessionRequestObject { reject: (value: unknown) => void } -interface Authorization { +export interface Authorization { type: 'video' project: string scopes: string[] @@ -171,6 +171,8 @@ interface Authorization { } signature: string expires_at?: number + audio_allowed?: boolean + video_allowed?: boolean } export interface RPCConnectResult { diff --git a/packages/js/src/BaseRoomSession.ts b/packages/js/src/BaseRoomSession.ts index 3509156f7..22340245d 100644 --- a/packages/js/src/BaseRoomSession.ts +++ b/packages/js/src/BaseRoomSession.ts @@ -52,6 +52,8 @@ export interface BaseRoomSession BaseComponentContract, BaseConnectionContract { join(): Promise + /** @internal */ + joinAudience(options?: { audio?: boolean; video?: boolean }): Promise leave(): Promise } @@ -173,7 +175,7 @@ export class RoomSessionConnection protected attachOnSubscribedWorkers(payload: VideoRoomEventParams) { this.runWorker('memberPositionWorker', { worker: workers.memberPositionWorker, - initialState: payload + initialState: payload, }) } diff --git a/packages/js/src/RoomSession.docs.ts b/packages/js/src/RoomSession.docs.ts index 929871495..d67778a1a 100644 --- a/packages/js/src/RoomSession.docs.ts +++ b/packages/js/src/RoomSession.docs.ts @@ -88,6 +88,9 @@ export interface RoomSessionDocs */ join(): Promise + /** @internal */ + joinAudience(options?: { audio?: boolean; video?: boolean }): Promise + /** * Leaves the room. This detaches all the locally originating streams from the * room. diff --git a/packages/js/src/RoomSession.ts b/packages/js/src/RoomSession.ts index a9bda6ebb..ba6862c48 100644 --- a/packages/js/src/RoomSession.ts +++ b/packages/js/src/RoomSession.ts @@ -1,8 +1,18 @@ -import { UserOptions, AssertSameType, getLogger } from '@signalwire/core' +import { + UserOptions, + AssertSameType, + getLogger, + Authorization, +} from '@signalwire/core' import { createClient } from './createClient' -import type { MakeRoomOptions } from './Client' import { BaseRoomSession } from './BaseRoomSession' -import { RoomSessionDocs } from './RoomSession.docs' +import { + getJoinAudienceMediaParams, + isValidJoinAudienceMediaParams, +} from './utils/roomSession' +import type { MakeRoomOptions } from './Client' +import type { RoomSessionDocs } from './RoomSession.docs' +import type { RoomSessionJoinAudienceParams } from './utils/interfaces' const VIDEO_CONSTRAINTS: MediaTrackConstraints = { aspectRatio: { ideal: 16 / 9 }, @@ -149,8 +159,55 @@ export const RoomSession = function (roomOptions: RoomSessionOptions) { }) } + const joinAudience = ( + params?: RoomSessionJoinAudienceParams + ) => { + return new Promise(async (resolve, reject) => { + try { + // @ts-expect-error + room.attachPreConnectWorkers() + + const session = await client.connect() + + // @ts-expect-error + const authState: Authorization = session._sessionAuthState + const mediaOptions = getJoinAudienceMediaParams({ + authState, + ...params, + }) + + if (!isValidJoinAudienceMediaParams(mediaOptions)) { + await session.disconnect() + return reject( + new Error( + '[joinAudience] Either (or both) `audio` and `video` must be `true` when calling this method.' + ) + ) + } + + // @ts-expect-error + room.updateMediaOptions(mediaOptions) + + room.once('room.subscribed', (payload) => { + // @ts-expect-error + room.attachOnSubscribedWorkers(payload) + resolve(room) + }) + + await room.join() + } catch (error) { + getLogger().error('RoomSession JoinAudience', error) + // Disconnect the underlay client in case of media/signaling errors + client.disconnect() + + reject(error) + } + }) + } + const interceptors = { join, + joinAudience, } as const return new Proxy>(room, { diff --git a/packages/js/src/utils/interfaces.ts b/packages/js/src/utils/interfaces.ts index f3cc89578..5fe52a52d 100644 --- a/packages/js/src/utils/interfaces.ts +++ b/packages/js/src/utils/interfaces.ts @@ -202,3 +202,8 @@ export interface RoomSessionDeviceMethods export interface RoomScreenShareMethods extends RoomMemberSelfMethodsInterface {} + +export interface RoomSessionJoinAudienceParams { + audio?: boolean + video?: boolean +} diff --git a/packages/js/src/utils/roomSession.ts b/packages/js/src/utils/roomSession.ts new file mode 100644 index 000000000..d9af5c397 --- /dev/null +++ b/packages/js/src/utils/roomSession.ts @@ -0,0 +1,54 @@ +import { getLogger } from '@signalwire/core' +import type { Authorization } from '@signalwire/core' +import type { RoomSessionJoinAudienceParams } from './interfaces' + +// `joinAudience` utils +const getJoinAudienceMediaParams = ({ + authState, + audio = true, + video = true, +}: RoomSessionJoinAudienceParams & { + authState: Authorization +}) => { + const getMediaValue = ({ + remote, + local, + kind, + }: { + remote?: boolean + local?: boolean + kind: 'audio' | 'video' + }) => { + if (!remote && local) { + getLogger().warn( + `[joinAudience] ${kind} is currently not allowed on this room.` + ) + } + + return !!(remote && local) + } + + return { + audio: false, + video: false, + negotiateAudio: getMediaValue({ + remote: authState.audio_allowed, + local: audio, + kind: 'audio', + }), + negotiateVideo: getMediaValue({ + remote: authState.video_allowed, + local: video, + kind: 'video', + }), + } +} + +const isValidJoinAudienceMediaParams = ( + options: Record +) => { + // At least one value must be true + return Object.values(options).some(Boolean) +} + +export { getJoinAudienceMediaParams, isValidJoinAudienceMediaParams } diff --git a/packages/webrtc/src/BaseConnection.ts b/packages/webrtc/src/BaseConnection.ts index f91a8041a..417533a1f 100644 --- a/packages/webrtc/src/BaseConnection.ts +++ b/packages/webrtc/src/BaseConnection.ts @@ -694,6 +694,19 @@ export class BaseConnection } } + /** @internal */ + updateMediaOptions(options: { + audio?: boolean + video?: boolean + negotiateAudio?: boolean + negotiateVideo?: boolean + }) { + this.options = { + ...this.options, + ...options, + } + } + /** @internal */ private _hangup(params: any = {}) { const {