From f00f2776a6b1b466eac88514e0b662603553a4ad Mon Sep 17 00:00:00 2001 From: Ammar Ansari Date: Mon, 2 Oct 2023 12:46:49 +0300 Subject: [PATCH] Realtime Video SDK with new interface (#886) * Realtime Video SDK with new interface * room session with the new interface * remove auto subscribe consumer * fix unit tests for video and room session * room member instance * unit tests for room session member * fix stack test * room session playback realtime-api instance * room session recording realtime-api instance * room session stream realtime-api instance * explicit methods for the realtime-api * fix build issue * separate workers for playback, recording and stream * video playground with the new interface * decorated promise for room session playback api * decorated promise for room session recording api * decorated promise for room session stream api * fix unit test cases * unit tests for decorated promises * update video play ground with decorated promise * fix e2e test case for the video * fix unit test * do not unsubscribe events * fix unit test * include changeset * streaming getter for room session * rename types --- .changeset/fluffy-birds-yawn.md | 33 + .../src/playwright/video.test.ts | 109 +- .../src/with-events/index.ts | 102 +- internal/stack-tests/src/video/app.ts | 18 +- packages/core/src/index.ts | 1 + packages/core/src/types/videoLayout.ts | 6 + packages/core/src/types/videoMember.ts | 41 +- packages/core/src/types/videoPlayback.ts | 22 +- packages/core/src/types/videoRecording.ts | 18 + packages/core/src/types/videoRoomSession.ts | 32 + packages/core/src/types/videoStream.ts | 14 + packages/core/src/utils/interfaces.ts | 6 + .../realtime-api/src/AutoSubscribeConsumer.ts | 54 - packages/realtime-api/src/Client.ts | 100 -- .../realtime-api/src/ListenSubscriber.test.ts | 9 + packages/realtime-api/src/ListenSubscriber.ts | 17 +- packages/realtime-api/src/SWClient.ts | 11 +- .../realtime-api/src/client/createClient.ts | 5 +- .../realtime-api/src/decoratePromise.test.ts | 158 +++ .../src/{voice => }/decoratePromise.ts | 14 +- packages/realtime-api/src/index.ts | 83 +- packages/realtime-api/src/types/video.ts | 993 +++++++++++++++++- packages/realtime-api/src/types/voice.ts | 12 + packages/realtime-api/src/video/BaseVideo.ts | 80 ++ .../src/video/RoomSession.test.ts | 155 +-- .../realtime-api/src/video/RoomSession.ts | 279 +++-- .../RoomSessionMember.test.ts | 114 +- .../RoomSessionMember.ts | 72 +- .../src/video/RoomSessionMember/index.ts | 1 + .../RoomSessionPlayback.test.ts | 213 ++++ .../RoomSessionPlayback.ts | 217 ++++ .../decoratePlaybackPromise.ts | 71 ++ .../src/video/RoomSessionPlayback/index.ts | 3 + .../RoomSessionRecording.test.ts | 199 ++++ .../RoomSessionRecording.ts | 138 +++ .../decorateRecordingPromise.ts | 53 + .../src/video/RoomSessionRecording/index.ts | 3 + .../RoomSessionStream.test.ts | 185 ++++ .../RoomSessionStream/RoomSessionStream.ts | 111 ++ .../decorateStreamPromise.ts | 53 + .../src/video/RoomSessionStream/index.ts | 3 + packages/realtime-api/src/video/Video.test.ts | 258 ++--- packages/realtime-api/src/video/Video.ts | 236 ++--- .../src/video/VideoClient.test.ts | 130 --- .../realtime-api/src/video/VideoClient.ts | 84 -- .../realtime-api/src/video/methods/index.ts | 38 + .../src/video/methods/methods.test.ts | 287 +++++ .../realtime-api/src/video/methods/methods.ts | 854 +++++++++++++++ .../src/video/workers/videoCallingWorker.ts | 73 +- .../src/video/workers/videoMemberWorker.ts | 8 +- .../src/video/workers/videoPlaybackWorker.ts | 16 +- .../src/video/workers/videoRecordingWorker.ts | 16 +- .../video/workers/videoRoomAudienceWorker.ts | 1 + .../src/video/workers/videoRoomWorker.ts | 54 +- .../src/video/workers/videoStreamWorker.ts | 16 +- .../CallCollect/decorateCollectPromise.ts | 2 +- .../voice/CallDetect/decorateDetectPromise.ts | 2 +- .../CallPlayback/decoratePlaybackPromise.ts | 2 +- .../voice/CallPrompt/decoratePromptPromise.ts | 2 +- .../CallRecording/decorateRecordingPromise.ts | 2 +- .../src/voice/CallTap/decorateTapPromise.ts | 2 +- packages/realtime-api/src/voice/Voice.ts | 10 +- .../src/voice/decoratePromise.test.ts | 89 -- 63 files changed, 4624 insertions(+), 1366 deletions(-) create mode 100644 .changeset/fluffy-birds-yawn.md delete mode 100644 packages/realtime-api/src/AutoSubscribeConsumer.ts delete mode 100644 packages/realtime-api/src/Client.ts create mode 100644 packages/realtime-api/src/decoratePromise.test.ts rename packages/realtime-api/src/{voice => }/decoratePromise.ts (88%) create mode 100644 packages/realtime-api/src/video/BaseVideo.ts rename packages/realtime-api/src/video/{ => RoomSessionMember}/RoomSessionMember.test.ts (61%) rename packages/realtime-api/src/video/{ => RoomSessionMember}/RoomSessionMember.ts (65%) create mode 100644 packages/realtime-api/src/video/RoomSessionMember/index.ts create mode 100644 packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.test.ts create mode 100644 packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.ts create mode 100644 packages/realtime-api/src/video/RoomSessionPlayback/decoratePlaybackPromise.ts create mode 100644 packages/realtime-api/src/video/RoomSessionPlayback/index.ts create mode 100644 packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.test.ts create mode 100644 packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.ts create mode 100644 packages/realtime-api/src/video/RoomSessionRecording/decorateRecordingPromise.ts create mode 100644 packages/realtime-api/src/video/RoomSessionRecording/index.ts create mode 100644 packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.test.ts create mode 100644 packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.ts create mode 100644 packages/realtime-api/src/video/RoomSessionStream/decorateStreamPromise.ts create mode 100644 packages/realtime-api/src/video/RoomSessionStream/index.ts delete mode 100644 packages/realtime-api/src/video/VideoClient.test.ts delete mode 100644 packages/realtime-api/src/video/VideoClient.ts create mode 100644 packages/realtime-api/src/video/methods/index.ts create mode 100644 packages/realtime-api/src/video/methods/methods.test.ts create mode 100644 packages/realtime-api/src/video/methods/methods.ts delete mode 100644 packages/realtime-api/src/voice/decoratePromise.test.ts diff --git a/.changeset/fluffy-birds-yawn.md b/.changeset/fluffy-birds-yawn.md new file mode 100644 index 000000000..213470334 --- /dev/null +++ b/.changeset/fluffy-birds-yawn.md @@ -0,0 +1,33 @@ +--- +'@signalwire/realtime-api': major +'@signalwire/core': major +--- + +- New interface for the realtime-api Video SDK. +- Listen function with _video_, _room_, _playback_, _recording_, and _stream_ objects. +- Listen param with `room.play`, `room.startRecording`, and `room.startStream` functions. +- Decorated promise for `room.play`, `room.startRecording`, and `room.startStream` functions. + +```js +import { SignalWire } from '@signalwire/realtime-api' + +const client = await SignalWire({ project, token }) + +const unsub = await client.video.listen({ + onRoomStarted: async (roomSession) => { + console.log('room session started', roomSession) + + await roomSession.listen({ + onPlaybackStarted: (playback) => { + console.log('plyaback started', playback) + } + }) + + // Promise resolves when playback ends. + await roomSession.play({ url: "http://.....", listen: { onEnded: () => {} } }) + }, + onRoomEnded: (roomSession) => { + console.log('room session ended', roomSession) + } +}) +``` \ No newline at end of file diff --git a/internal/e2e-realtime-api/src/playwright/video.test.ts b/internal/e2e-realtime-api/src/playwright/video.test.ts index e052d0fdf..21d54c287 100644 --- a/internal/e2e-realtime-api/src/playwright/video.test.ts +++ b/internal/e2e-realtime-api/src/playwright/video.test.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test' import { uuid } from '@signalwire/core' -import { Video } from '@signalwire/realtime-api' +import { SignalWire, Video } from '@signalwire/realtime-api' import { createRoomAndRecordPlay, createRoomSession, @@ -10,8 +10,7 @@ import { SERVER_URL } from '../../utils' test.describe('Video', () => { test('should join the room and listen for events', async ({ browser }) => { - const videoClient = new Video.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.RELAY_HOST, project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, @@ -23,19 +22,20 @@ test.describe('Video', () => { const roomSessionCreated = new Map() const findRoomSessionsByPrefix = async () => { - const { roomSessions } = await videoClient.getRoomSessions() + const { roomSessions } = await client.video.getRoomSessions() return roomSessions.filter((r) => r.name.startsWith(prefix)) } - videoClient.on('room.started', async (roomSession) => { - console.log('Room started', roomSession.id) - if (roomSession.name.startsWith(prefix)) { - roomSessionCreated.set(roomSession.id, roomSession) - } - }) - - videoClient.on('room.ended', async (roomSession) => { - console.log('Room ended', roomSession.id) + await client.video.listen({ + onRoomStarted: (roomSession) => { + console.log('Room started', roomSession.id) + if (roomSession.name.startsWith(prefix)) { + roomSessionCreated.set(roomSession.id, roomSession) + } + }, + onRoomEnded: (roomSession) => { + console.log('Room ended', roomSession.id) + }, }) const roomSessionsAtStart = await findRoomSessionsByPrefix() @@ -77,47 +77,55 @@ test.describe('Video', () => { for (let index = 0; index < roomSessionsRunning.length; index++) { const rs = roomSessionsRunning[index] - await new Promise((resolve) => { - rs.on('recording.ended', noop) - rs.on('playback.ended', noop) - rs.on('room.updated', noop) - rs.on('room.subscribed', resolve) + await new Promise(async (resolve) => { + await rs.listen({ + onRecordingEnded: noop, + onPlaybackEnded: noop, + onRoomUpdated: noop, + onRoomSubscribed: resolve, + }) }) await new Promise(async (resolve) => { - rs.on('recording.ended', () => { - resolve() + await rs.listen({ + onRecordingEnded: () => resolve(), }) const { recordings } = await rs.getRecordings() await Promise.all(recordings.map((r) => r.stop())) }) await new Promise(async (resolve) => { - rs.on('playback.ended', () => { - resolve() + await rs.listen({ + onPlaybackEnded: () => resolve(), }) const { playbacks } = await rs.getPlaybacks() await Promise.all(playbacks.map((p) => p.stop())) }) await new Promise(async (resolve, reject) => { - rs.on('room.updated', (roomSession) => { - if (roomSession.locked === true) { - resolve() - } else { - reject(new Error('Not locked')) - } + const unsub = await rs.listen({ + onRoomUpdated: async (roomSession) => { + if (roomSession.locked === true) { + resolve() + await unsub() + } else { + reject(new Error('Not locked')) + } + }, }) await rs.lock() }) await new Promise(async (resolve, reject) => { - rs.on('room.updated', (roomSession) => { - if (roomSession.locked === false) { - resolve() - } else { - reject(new Error('Still locked')) - } + const unsub = await rs.listen({ + onRoomUpdated: async (roomSession) => { + if (roomSession.locked === false) { + resolve() + await unsub() + } else { + reject(new Error('Not locked')) + } + }, }) await rs.unlock() }) @@ -132,32 +140,35 @@ test.describe('Video', () => { test('should join the room and set hand raise priority', async ({ browser, }) => { - const page = await browser.newPage() - await page.goto(SERVER_URL) - enablePageLogs(page, '[pageOne]') - - // Create a realtime-api Video client - const videoClient = new Video.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.RELAY_HOST, project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, debug: { logWsTraffic: true }, }) + const page = await browser.newPage() + await page.goto(SERVER_URL) + enablePageLogs(page, '[pageOne]') + const prefix = uuid() const roomName = `${prefix}-hand-raise-priority-e2e` const findRoomSession = async () => { - const { roomSessions } = await videoClient.getRoomSessions() + const { roomSessions } = await client.video.getRoomSessions() return roomSessions.filter((r) => r.name.startsWith(prefix)) } // Listen for realtime-api event - videoClient.on('room.started', (room) => { - room.on('room.updated', (room) => { - console.log('>> room.updated', room.name) - }) + await client.video.listen({ + onRoomStarted: async (roomSession) => { + console.log('>> room.started', roomSession.name) + await roomSession.listen({ + onRoomUpdated: (room) => { + console.log('>> room.updated', room.name) + }, + }) + }, }) // Room length should be 0 before start @@ -203,8 +214,10 @@ test.describe('Video', () => { // Set the hand raise prioritization via Node SDK const roomSessionNodeUpdated = await new Promise( async (resolve, _reject) => { - roomSessionNode.on('room.updated', (room) => { - resolve(room) + await roomSessionNode.listen({ + onRoomUpdated: (room) => { + resolve(room) + }, }) await roomSessionNode.setPrioritizeHandraise(true) } diff --git a/internal/playground-realtime-api/src/with-events/index.ts b/internal/playground-realtime-api/src/with-events/index.ts index 59a10c702..4fb271ec8 100644 --- a/internal/playground-realtime-api/src/with-events/index.ts +++ b/internal/playground-realtime-api/src/with-events/index.ts @@ -1,50 +1,100 @@ -import { Video } from '@signalwire/realtime-api' +import { Video, SignalWire } from '@signalwire/realtime-api' async function run() { try { - const video = new Video.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.HOST || 'relay.swire.io', project: process.env.PROJECT as string, token: process.env.TOKEN as string, debug: { - logWsTraffic: true, + // logWsTraffic: true, }, }) - const roomSessionHandler = (room: Video.RoomSession) => { - console.log('Room started --->', room.id, room.name, room.members) - room.on('room.subscribed', (room) => { - console.log('Room Subscribed --->', room.id, room.members) - }) + const unsubVideo = await client.video.listen({ + onRoomStarted(room) { + console.log('🟢 onRoomStarted 🟢', room.id, room.name) + roomSessionHandler(room) + }, + onRoomEnded(room) { + console.log('🔴 onRoomEnded 🔴', room.id, room.name) + }, + }) - room.on('member.updated', () => { - console.log('Member updated --->') - }) + const roomSessionHandler = async (room: Video.RoomSession) => { + const unsubRoom = await room.listen({ + onRoomSubscribed: (room) => { + console.log('onRoomSubscribed', room.id, room.name) + }, + onRoomStarted: (room) => { + console.log('onRoomStarted', room.id, room.name) + }, + onRoomUpdated: (room) => { + console.log('onRoomUpdated', room.id, room.name) + }, + onRoomEnded: (room) => { + console.log('onRoomEnded', room.id, room.name) + }, + onMemberJoined: async (member) => { + console.log('onMemberJoined --->', member.id, member.name) - room.on('member.joined', (member) => { - console.log('Member joined --->', member.id, member.name) - }) + const play = await room + .play({ + url: 'https://cdn.signalwire.com/default-music/welcome.mp3', + listen: { + onStarted: (playback) => { + console.log('onStarted', playback.id, playback.url) + }, + onUpdated: (playback) => { + console.log('onUpdated', playback.id, playback.url) + }, + onEnded: (playback) => { + console.log('onEnded', playback.id, playback.url) + }, + }, + }) + .onStarted() + console.log('play', play.id) + + setTimeout(async () => { + await play.pause() - room.on('member.left', (member) => { - console.log('Member left --->', member.id, member.name) + setTimeout(async () => { + await play.stop() + }, 5000) + }, 10000) + }, + onMemberUpdated: (member) => { + console.log('onMemberUpdated', member.id, member.name) + }, + onMemberTalking: (member) => { + console.log('onMemberTalking', member.id, member.name) + }, + onMemberLeft: (member) => { + console.log('onMemberLeft', member.id, member.name) + }, + onPlaybackStarted: (playback) => { + console.log('onPlaybackStarted', playback.id, playback.url) + }, + onPlaybackUpdated: (playback) => { + console.log('onPlaybackUpdated', playback.id, playback.url) + }, + onPlaybackEnded: (playback) => { + console.log('onPlaybackEnded', playback.id, playback.url) + }, }) } - video.on('room.started', roomSessionHandler) - - video.on('room.ended', (room) => { - console.log('🔴 ROOOM ENDED 🔴', `${room}`, room.name) - }) - video._session.on('session.connected', () => { + // @ts-expect-error + client.video._client.session.on('session.connected', () => { console.log('SESSION CONNECTED!') }) console.log('Client Running..') - const { roomSessions } = await video.getRoomSessions() + const { roomSessions } = await client.video.getRoomSessions() - roomSessions.forEach(async (room: any) => { + roomSessions.forEach(async (room: Video.RoomSession) => { console.log('>> Room Session: ', room.id, room.displayName) roomSessionHandler(room) @@ -52,7 +102,7 @@ async function run() { console.log('Members:', r) // await room.removeAllMembers() - const { roomSession } = await video.getRoomSessionById(room.id) + const { roomSession } = await client.video.getRoomSessionById(room.id) console.log('Room Session By ID:', roomSession.displayName) }) } catch (error) { diff --git a/internal/stack-tests/src/video/app.ts b/internal/stack-tests/src/video/app.ts index e63cf4bd5..458fc45d9 100644 --- a/internal/stack-tests/src/video/app.ts +++ b/internal/stack-tests/src/video/app.ts @@ -1,20 +1,22 @@ -import { Video } from '@signalwire/realtime-api' +import { SignalWire } from '@signalwire/realtime-api' import tap from 'tap' async function run() { try { - const video = new Video.Client({ - // @ts-expect-error + const client = await SignalWire({ host: process.env.RELAY_HOST || 'relay.swire.io', project: process.env.RELAY_PROJECT as string, token: process.env.RELAY_TOKEN as string, }) - tap.ok(video.on, 'video.on is defined') - tap.ok(video.once, 'video.once is defined') - tap.ok(video.off, 'video.off is defined') - tap.ok(video.subscribe, 'video.subscribe is defined') - tap.ok(video.removeAllListeners, 'video.removeAllListeners is defined') + tap.ok(client.video, 'client.video is defined') + tap.ok(client.video.listen, 'client.video.listen is defined') + tap.ok(client.video.getRoomSessions, 'video.getRoomSessions is defined') + tap.ok( + client.video.getRoomSessionById, + 'video.getRoomSessionById is defined' + ) + tap.ok(client.disconnect, 'video.disconnect is defined') process.exit(0) } catch (error) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 420b27ee9..dcfe75468 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,6 +89,7 @@ export type { SDKActions, ReduxComponent, } from './redux/interfaces' +export type { SDKStore } from './redux' export type { ToExternalJSONResult } from './utils' export * as actions from './redux/actions' export * as sagaHelpers from './redux/utils/sagaHelpers' diff --git a/packages/core/src/types/videoLayout.ts b/packages/core/src/types/videoLayout.ts index cfe83b671..f9be81738 100644 --- a/packages/core/src/types/videoLayout.ts +++ b/packages/core/src/types/videoLayout.ts @@ -3,12 +3,18 @@ import { VideoPosition } from '..' import type { CamelToSnakeCase, ToInternalVideoEvent } from './utils' export type LayoutChanged = 'layout.changed' +export type OnLayoutChanged = 'onLayoutChanged' /** * List of public event names */ export type VideoLayoutEventNames = LayoutChanged +/** + * List of public listener names + */ +export type VideoLayoutListenerNames = OnLayoutChanged + /** * List of internal events * @internal diff --git a/packages/core/src/types/videoMember.ts b/packages/core/src/types/videoMember.ts index 9196f1cf6..1423ba016 100644 --- a/packages/core/src/types/videoMember.ts +++ b/packages/core/src/types/videoMember.ts @@ -61,7 +61,15 @@ export type MemberTalking = 'member.talking' export type MemberPromoted = 'member.promoted' export type MemberDemoted = 'member.demoted' -// Generated by the SDK +/** + * Public listener types + */ +export type OnMemberJoined = 'onMemberJoined' +export type OnMemberLeft = 'onMemberLeft' +export type OnMemberUpdated = 'onMemberUpdated' +export type OnMemberTalking = 'onMemberTalking' +export type OnMemberPromoted = 'onMemberPromoted' +export type OnMemberDemoted = 'onMemberDemoted' /** * @privateRemarks @@ -72,6 +80,7 @@ export type MemberDemoted = 'member.demoted' * room. */ export type MemberListUpdated = 'memberList.updated' +export type OnMemberListUpdated = 'onMemberListUpdated' /** * See {@link MEMBER_UPDATED_EVENTS} for the full list of events. @@ -79,6 +88,17 @@ export type MemberListUpdated = 'memberList.updated' export type MemberUpdatedEventNames = (typeof MEMBER_UPDATED_EVENTS)[number] export type MemberTalkingStarted = 'member.talking.started' export type MemberTalkingEnded = 'member.talking.ended' + +export type OnMemberDeaf = 'onMemberDeaf' +export type OnMemberVisible = 'onMemberVisible' +export type OnMemberAudioMuted = 'onMemberAudioMuted' +export type OnMemberVideoMuted = 'onMemberVideoMuted' +export type OnMemberInputVolume = 'onMemberInputVolume' +export type OnMemberOutputVolume = 'onMemberOutputVolume' +export type OnMemberInputSensitivity = 'onMemberInputSensitivity' +export type OnMemberTalkingStarted = 'onMemberTalkingStarted' +export type OnMemberTalkingEnded = 'onMemberTalkingEnded' + /** * Use `member.talking.started` instead * @deprecated @@ -97,6 +117,11 @@ export type MemberTalkingEventNames = | MemberTalkingStart | MemberTalkingStop +export type MemberTalkingListenerNames = + | OnMemberTalking + | OnMemberTalkingStarted + | OnMemberTalkingEnded + /** * List of public events */ @@ -108,6 +133,20 @@ export type VideoMemberEventNames = | MemberTalkingEventNames | MemberListUpdated +export type VideoMemberListenerNames = + | OnMemberJoined + | OnMemberLeft + | OnMemberUpdated + | OnMemberDeaf + | OnMemberVisible + | OnMemberAudioMuted + | OnMemberVideoMuted + | OnMemberInputVolume + | OnMemberOutputVolume + | OnMemberInputSensitivity + | MemberTalkingListenerNames + | OnMemberListUpdated + export type InternalMemberUpdatedEventNames = (typeof INTERNAL_MEMBER_UPDATED_EVENTS)[number] diff --git a/packages/core/src/types/videoPlayback.ts b/packages/core/src/types/videoPlayback.ts index 1670bfa69..97bc903f9 100644 --- a/packages/core/src/types/videoPlayback.ts +++ b/packages/core/src/types/videoPlayback.ts @@ -1,4 +1,5 @@ import type { SwEvent } from '.' +import { MapToPubSubShape } from '..' import type { CamelToSnakeCase, ToInternalVideoEvent, @@ -13,6 +14,13 @@ export type PlaybackStarted = 'playback.started' export type PlaybackUpdated = 'playback.updated' export type PlaybackEnded = 'playback.ended' +/** + * Public listener types + */ +export type OnPlaybackStarted = 'onPlaybackStarted' +export type OnPlaybackUpdated = 'onPlaybackUpdated' +export type OnPlaybackEnded = 'onPlaybackEnded' + /** * List of public event names */ @@ -21,6 +29,14 @@ export type VideoPlaybackEventNames = | PlaybackUpdated | PlaybackEnded +/** + * List of public listener names + */ +export type VideoPlaybackListenerNames = + | OnPlaybackStarted + | OnPlaybackUpdated + | OnPlaybackEnded + /** * List of internal events * @internal @@ -42,7 +58,7 @@ export interface VideoPlaybackContract { state: 'playing' | 'paused' | 'completed' /** The current playback position, in milliseconds. */ - position: number; + position: number /** Whether the seek function can be used for this playback. */ seekable: boolean @@ -54,7 +70,7 @@ export interface VideoPlaybackContract { volume: number /** Start time, if available */ - startedAt: Date + startedAt?: Date /** End time, if available */ endedAt?: Date @@ -162,3 +178,5 @@ export type VideoPlaybackEventParams = | VideoPlaybackStartedEventParams | VideoPlaybackUpdatedEventParams | VideoPlaybackEndedEventParams + +export type VideoPlaybackAction = MapToPubSubShape diff --git a/packages/core/src/types/videoRecording.ts b/packages/core/src/types/videoRecording.ts index 22aa21620..9a2decf88 100644 --- a/packages/core/src/types/videoRecording.ts +++ b/packages/core/src/types/videoRecording.ts @@ -1,4 +1,5 @@ import type { SwEvent } from '.' +import { MapToPubSubShape } from '..' import type { CamelToSnakeCase, ConvertToInternalTypes, @@ -14,6 +15,13 @@ export type RecordingStarted = 'recording.started' export type RecordingUpdated = 'recording.updated' export type RecordingEnded = 'recording.ended' +/** + * Public listener types + */ +export type OnRecordingStarted = 'onRecordingStarted' +export type OnRecordingUpdated = 'onRecordingUpdated' +export type OnRecordingEnded = 'onRecordingEnded' + /** * List of public event names */ @@ -22,6 +30,14 @@ export type VideoRecordingEventNames = | RecordingUpdated | RecordingEnded +/** + * List of public listener names + */ +export type VideoRecordingListenerNames = + | OnRecordingStarted + | OnRecordingUpdated + | OnRecordingEnded + /** * List of internal events * @internal @@ -150,3 +166,5 @@ export type VideoRecordingEventParams = | VideoRecordingStartedEventParams | VideoRecordingUpdatedEventParams | VideoRecordingEndedEventParams + +export type VideoRecordingAction = MapToPubSubShape diff --git a/packages/core/src/types/videoRoomSession.ts b/packages/core/src/types/videoRoomSession.ts index 9bb80344b..271e7187b 100644 --- a/packages/core/src/types/videoRoomSession.ts +++ b/packages/core/src/types/videoRoomSession.ts @@ -11,6 +11,7 @@ import type { } from './utils' import type { InternalVideoMemberEntity } from './videoMember' import * as Rooms from '../rooms' +import { MapToPubSubShape } from '../redux/interfaces' /** * Public event types @@ -26,6 +27,17 @@ export type RoomJoined = 'room.joined' export type RoomLeft = 'room.left' export type RoomAudienceCount = 'room.audienceCount' +/** + * Public listener types + */ +export type OnRoomStarted = 'onRoomStarted' +export type OnRoomSubscribed = 'onRoomSubscribed' +export type OnRoomUpdated = 'onRoomUpdated' +export type OnRoomEnded = 'onRoomEnded' +export type OnRoomAudienceCount = 'onRoomAudienceCount' +export type OnRoomJoined = 'onRoomJoined' +export type OnRoomLeft = 'onRoomLeft' + export type RoomLeftEventParams = { reason?: BaseConnectionContract['leaveReason'] } @@ -45,6 +57,17 @@ export type VideoRoomSessionEventNames = | RoomJoined // only used in `js` (emitted by `webrtc`) | RoomLeft // only used in `js` +/** + * List of public listener names + */ +export type VideoRoomSessionListenerNames = + | OnRoomStarted + | OnRoomSubscribed + | OnRoomUpdated + | OnRoomEnded + | OnRoomJoined // only used in `js` (emitted by `webrtc`) + | OnRoomLeft // only used in `js` + /** * List of internal events * @internal @@ -946,3 +969,12 @@ export type VideoRoomEventParams = | VideoRoomSubscribedEventParams | VideoRoomUpdatedEventParams | VideoRoomEndedEventParams + +export type VideoRoomStartedAction = MapToPubSubShape + +export type VideoRoomEndedAction = MapToPubSubShape + +export type VideoRoomUpdatedAction = MapToPubSubShape + +export type VideoRoomSubscribedAction = + MapToPubSubShape diff --git a/packages/core/src/types/videoStream.ts b/packages/core/src/types/videoStream.ts index 09af7a6e6..e02b62c0d 100644 --- a/packages/core/src/types/videoStream.ts +++ b/packages/core/src/types/videoStream.ts @@ -1,4 +1,5 @@ import type { SwEvent } from '.' +import { MapToPubSubShape } from '..' import type { CamelToSnakeCase, ConvertToInternalTypes, @@ -13,11 +14,22 @@ import type { export type StreamStarted = 'stream.started' export type StreamEnded = 'stream.ended' +/** + * Public listener types + */ +export type OnStreamStarted = 'onStreamStarted' +export type OnStreamEnded = 'onStreamEnded' + /** * List of public event names */ export type VideoStreamEventNames = StreamStarted | StreamEnded +/** + * List of public listener names + */ +export type VideoStreamListenerNames = OnStreamStarted | OnStreamEnded + /** * List of internal events * @internal @@ -124,3 +136,5 @@ export type VideoStreamEvent = VideoStreamStartedEvent | VideoStreamEndedEvent export type VideoStreamEventParams = | VideoStreamStartedEventParams | VideoStreamEndedEventParams + +export type VideoStreamAction = MapToPubSubShape diff --git a/packages/core/src/utils/interfaces.ts b/packages/core/src/utils/interfaces.ts index 2ad0d4600..df5ec78a9 100644 --- a/packages/core/src/utils/interfaces.ts +++ b/packages/core/src/utils/interfaces.ts @@ -56,6 +56,7 @@ export type JSONRPCMethod = | 'signalwire.event' | 'signalwire.reauthenticate' | 'signalwire.subscribe' + | 'signalwire.unsubscribe' | WebRTCMethod | RoomMethod | VertoMethod @@ -69,6 +70,11 @@ export type JSONRPCSubscribeMethod = Extract< 'signalwire.subscribe' | 'chat.subscribe' > +export type JSONRPCUnSubscribeMethod = Extract< + JSONRPCMethod, + 'signalwire.unsubscribe' +> + export interface JSONRPCRequest { jsonrpc: '2.0' id: string diff --git a/packages/realtime-api/src/AutoSubscribeConsumer.ts b/packages/realtime-api/src/AutoSubscribeConsumer.ts deleted file mode 100644 index 8607e1357..000000000 --- a/packages/realtime-api/src/AutoSubscribeConsumer.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - BaseComponentOptions, - BaseConsumer, - EventEmitter, - debounce, - validateEventsToSubscribe, -} from '@signalwire/core' - -export class AutoSubscribeConsumer< - EventTypes extends EventEmitter.ValidEventTypes -> extends BaseConsumer { - /** @internal */ - private debouncedSubscribe: ReturnType - - constructor(options: BaseComponentOptions) { - super(options) - - this.debouncedSubscribe = debounce(this.subscribe, 100) - } - - /** @internal */ - protected override getSubscriptions() { - const eventNamesWithPrefix = this.eventNames().map( - (event) => `video.${String(event)}` - ) as EventEmitter.EventNames[] - return validateEventsToSubscribe(eventNamesWithPrefix) - } - - override on>( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.on(event, fn) - this.debouncedSubscribe() - return instance - } - - override once>( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.once(event, fn) - this.debouncedSubscribe() - return instance - } - - override off>( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.off(event, fn) - return instance - } -} diff --git a/packages/realtime-api/src/Client.ts b/packages/realtime-api/src/Client.ts deleted file mode 100644 index 1da7c31d5..000000000 --- a/packages/realtime-api/src/Client.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - BaseClient, - EventsPrefix, - SessionState, - ClientContract, - ClientEvents, -} from '@signalwire/core' -import { createVideoObject, Video } from './video/Video' - -/** - * A real-time Client. - * - * To construct an instance of this class, please use {@link createClient}. - * - * Example usage: - * ```typescript - * import {createClient} from '@signalwire/realtime-api' - * - * // Obtain a client: - * const client = await createClient({ project, token }) - * - * // Listen on events: - * client.video.on('room.started', async (room) => { }) - * - * // Connect: - * await client.connect() - * ``` - * @deprecated It's no longer needed to create the client - * manually. You can use the product constructors, like - * Video.Client, to access the same functionality. - */ -export interface RealtimeClient - extends ClientContract { - /** - * Connects this client to the SignalWire network. - * - * As a general best practice, it is suggested to connect the event listeners - * *before* connecting the client, so that no events are lost. - * - * @returns Upon connection, asynchronously returns an instance of this same - * object. - * - * @example - * ```typescript - * const client = await createClient({project, token}) - * client.video.on('room.started', async (roomSession) => { }) // connect events - * await client.connect() - * ``` - */ - connect(): Promise - - /** - * Disconnects this client from the SignalWire network. - */ - disconnect(): void - - /** - * Access the Video API Consumer - */ - video: Video -} - -type ClientNamespaces = Video - -export class Client extends BaseClient { - private _consumers: Map = new Map() - - async onAuth(session: SessionState) { - try { - if (session.authStatus === 'authorized') { - this._consumers.forEach((consumer) => { - consumer.subscribe() - }) - } - } catch (error) { - this.logger.error('Client subscription failed.') - this.disconnect() - - /** - * TODO: This error is not being catched by us so it's - * gonna appear as `UnhandledPromiseRejectionWarning`. - * The reason we are re-throwing here is because if - * this happens something serious happened and the app - * won't work anymore since subscribes aren't working. - */ - throw error - } - } - - get video(): Video { - if (this._consumers.has('video')) { - return this._consumers.get('video')! - } - const video = createVideoObject({ - store: this.store, - }) - this._consumers.set('video', video) - return video - } -} diff --git a/packages/realtime-api/src/ListenSubscriber.test.ts b/packages/realtime-api/src/ListenSubscriber.test.ts index b65daf3f5..e48ef5d9f 100644 --- a/packages/realtime-api/src/ListenSubscriber.test.ts +++ b/packages/realtime-api/src/ListenSubscriber.test.ts @@ -40,6 +40,15 @@ describe('ListenSubscriber', () => { }) describe('listen', () => { + it.each([undefined, {}, false, 'blah'])( + 'should throw an error on wrong listen params', + async (param) => { + await expect(listentSubscriber.listen(param)).rejects.toThrow( + 'Invalid params!' + ) + } + ) + it('should call the subscribe method with listen options', async () => { const subscribeMock = jest.spyOn(listentSubscriber, 'subscribe') diff --git a/packages/realtime-api/src/ListenSubscriber.ts b/packages/realtime-api/src/ListenSubscriber.ts index b0a0737f9..7ac9826e6 100644 --- a/packages/realtime-api/src/ListenSubscriber.ts +++ b/packages/realtime-api/src/ListenSubscriber.ts @@ -33,6 +33,11 @@ export class ListenSubscriber< return this._emitter } + protected eventNames() { + return this.emitter.eventNames() + } + + /** @internal */ emit>( event: T, ...args: EventEmitter.EventArgs @@ -64,6 +69,14 @@ export class ListenSubscriber< public listen(listeners: T) { return new Promise<() => Promise>(async (resolve, reject) => { try { + if ( + !listeners || + listeners?.constructor !== Object || + Object.keys(listeners).length < 1 + ) { + throw new Error('Invalid params!') + } + const unsub = await this.subscribe(listeners) resolve(unsub) } catch (error) { @@ -103,7 +116,7 @@ export class ListenSubscriber< return unsub } - private _attachListeners(listeners: T) { + protected _attachListeners(listeners: T) { const listenerKeys = Object.keys(listeners) as Array> listenerKeys.forEach((key) => { if (typeof listeners[key] === 'function' && this._eventMap[key]) { @@ -115,7 +128,7 @@ export class ListenSubscriber< }) } - private _detachListeners(listeners: T) { + protected _detachListeners(listeners: T) { const listenerKeys = Object.keys(listeners) as Array> listenerKeys.forEach((key) => { if (typeof listeners[key] === 'function' && this._eventMap[key]) { diff --git a/packages/realtime-api/src/SWClient.ts b/packages/realtime-api/src/SWClient.ts index 4eddfe271..b0faae615 100644 --- a/packages/realtime-api/src/SWClient.ts +++ b/packages/realtime-api/src/SWClient.ts @@ -6,6 +6,7 @@ import { Messaging } from './messaging/Messaging' import { PubSub } from './pubSub/PubSub' import { Chat } from './chat/Chat' import { Voice } from './voice/Voice' +import { Video } from './video/Video' export interface SWClientOptions { host?: string @@ -19,10 +20,11 @@ export interface SWClientOptions { export class SWClient { private _task: Task + private _messaging: Messaging private _pubSub: PubSub private _chat: Chat private _voice: Voice - private _messaging: Messaging + private _video: Video public userOptions: SWClientOptions public client: Client @@ -81,4 +83,11 @@ export class SWClient { } return this._voice } + + get video() { + if (!this._video) { + this._video = new Video(this) + } + return this._video + } } diff --git a/packages/realtime-api/src/client/createClient.ts b/packages/realtime-api/src/client/createClient.ts index fc46a8460..792ee0d7c 100644 --- a/packages/realtime-api/src/client/createClient.ts +++ b/packages/realtime-api/src/client/createClient.ts @@ -1,4 +1,4 @@ -import { connect, ClientEvents } from '@signalwire/core' +import { connect, ClientEvents, SDKStore } from '@signalwire/core' import { setupInternals } from '../utils/internals' import { Client } from './Client' @@ -6,10 +6,11 @@ export const createClient = (userOptions: { project: string token: string logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' + store?: SDKStore }) => { const { emitter, store } = setupInternals(userOptions) const client = connect({ - store, + store: userOptions.store ?? store, Component: Client, })({ ...userOptions, store, emitter }) diff --git a/packages/realtime-api/src/decoratePromise.test.ts b/packages/realtime-api/src/decoratePromise.test.ts new file mode 100644 index 000000000..c23d8fd9f --- /dev/null +++ b/packages/realtime-api/src/decoratePromise.test.ts @@ -0,0 +1,158 @@ +import { Voice } from './voice/Voice' +import { Call } from './voice/Call' +import { decoratePromise, DecoratePromiseOptions } from './decoratePromise' +import { createClient } from './client/createClient' +import { Video } from './video/Video' +import { RoomSession } from './video/RoomSession' + +class MockApi { + _ended: boolean = false + + get hasEnded() { + return this._ended + } + + get getter1() { + return 'getter1' + } + + get getter2() { + return 'getter2' + } + + method1() {} + + method2() {} +} + +describe('decoratePromise', () => { + describe('Voice Call', () => { + let voice: Voice + let call: Call + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + voice = new Voice(swClientMock) + + call = new Call({ voice }) + }) + + it('should decorate a promise correctly', async () => { + const mockInnerPromise = Promise.resolve(new MockApi()) + + const options: DecoratePromiseOptions = { + promise: mockInnerPromise, + namespace: 'playback', + methods: ['method1', 'method2'], + getters: ['getter1', 'getter2'], + } + + const decoratedPromise = decoratePromise.call(call, options) + + // All properties before the promise resolve + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method1', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method2', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('getter1') + expect(decoratedPromise).toHaveProperty('getter2') + + // @ts-expect-error + const onStarted = decoratedPromise.onStarted() + expect(onStarted).toBeInstanceOf(Promise) + expect(await onStarted).toBeInstanceOf(MockApi) + + // @ts-expect-error + const onEnded = decoratedPromise.onEnded() + expect(onEnded).toBeInstanceOf(Promise) + // @ts-expect-error + call.emit('playback.ended', new MockApi()) + expect(await onEnded).toBeInstanceOf(MockApi) + + const resolved = await decoratedPromise + + // All properties after the promise resolve + expect(resolved).not.toHaveProperty('onStarted', expect.any(Function)) + expect(resolved).not.toHaveProperty('onEnded', expect.any(Function)) + expect(resolved).toHaveProperty('method1', expect.any(Function)) + expect(resolved).toHaveProperty('method2', expect.any(Function)) + expect(resolved).toHaveProperty('getter1') + expect(resolved).toHaveProperty('getter2') + }) + }) + + describe('Video RoomSession', () => { + let video: Video + let roomSession: RoomSession + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + } + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + + beforeEach(() => { + // @ts-expect-error + video = new Voice(swClientMock) + // @ts-expect-error + roomSession = new RoomSession({ video, payload: {} }) + }) + + it('should decorate a promise correctly', async () => { + const mockInnerPromise = Promise.resolve(new MockApi()) + + const options: DecoratePromiseOptions = { + promise: mockInnerPromise, + namespace: 'playback', + methods: ['method1', 'method2'], + getters: ['getter1', 'getter2'], + } + + const decoratedPromise = decoratePromise.call(roomSession, options) + + // All properties before the promise resolve + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method1', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('method2', expect.any(Function)) + expect(decoratedPromise).toHaveProperty('getter1') + expect(decoratedPromise).toHaveProperty('getter2') + + // @ts-expect-error + const onStarted = decoratedPromise.onStarted() + expect(onStarted).toBeInstanceOf(Promise) + expect(await onStarted).toBeInstanceOf(MockApi) + + // @ts-expect-error + const onEnded = decoratedPromise.onEnded() + expect(onEnded).toBeInstanceOf(Promise) + // @ts-expect-error + roomSession.emit('playback.ended', new MockApi()) + expect(await onEnded).toBeInstanceOf(MockApi) + + const resolved = await decoratedPromise + + // All properties after the promise resolve + expect(resolved).not.toHaveProperty('onStarted', expect.any(Function)) + expect(resolved).not.toHaveProperty('onEnded', expect.any(Function)) + expect(resolved).toHaveProperty('method1', expect.any(Function)) + expect(resolved).toHaveProperty('method2', expect.any(Function)) + expect(resolved).toHaveProperty('getter1') + expect(resolved).toHaveProperty('getter2') + }) + }) +}) diff --git a/packages/realtime-api/src/voice/decoratePromise.ts b/packages/realtime-api/src/decoratePromise.ts similarity index 88% rename from packages/realtime-api/src/voice/decoratePromise.ts rename to packages/realtime-api/src/decoratePromise.ts index 1b6b50eb6..189e169b2 100644 --- a/packages/realtime-api/src/voice/decoratePromise.ts +++ b/packages/realtime-api/src/decoratePromise.ts @@ -1,14 +1,22 @@ -import { Call } from './Call' +import { Call } from './voice/Call' +import { RoomSession } from './video/RoomSession' export interface DecoratePromiseOptions { promise: Promise - namespace: 'playback' | 'recording' | 'prompt' | 'tap' | 'detect' | 'collect' + namespace: + | 'playback' + | 'recording' + | 'prompt' + | 'tap' + | 'detect' + | 'collect' + | 'stream' methods: string[] getters: string[] } export function decoratePromise( - this: Call, + this: Call | RoomSession, options: DecoratePromiseOptions ): Promise { const { promise: innerPromise, namespace, methods, getters } = options diff --git a/packages/realtime-api/src/index.ts b/packages/realtime-api/src/index.ts index b030ba2f8..ee72b4e7a 100644 --- a/packages/realtime-api/src/index.ts +++ b/packages/realtime-api/src/index.ts @@ -1,87 +1,6 @@ -/** - * You can use the realtime SDK to listen for and react to events from - * SignalWire's RealTime APIs. - * - * To get started, create a realtime client, for example with - * {@link Video.Client} and listen for events. For example: - * - * ```javascript - * import { Video } from '@signalwire/realtime-api' - * - * const video = new Video.Client({ - * project: '', - * token: '' - * }) - * - * video.on('room.started', async (roomSession) => { - * console.log("Room started") - * - * roomSession.on('member.joined', async (member) => { - * console.log(member) - * }) - * }); - * ``` - * - * @module - */ - -/** - * Access the Video API Consumer. You can instantiate a {@link Video.Client} to - * subscribe to Video events. Please check {@link Video.VideoClientApiEvents} - * for the full list of events that a {@link Video.Client} can subscribe to. - * - * @example - * - * The following example logs whenever a room session is started or a user joins - * it: - * - * ```javascript - * const video = new Video.Client({ project, token }) - * - * // Listen for events: - * video.on('room.started', async (roomSession) => { - * console.log('Room has started:', roomSession.name) - * - * roomSession.on('member.joined', async (member) => { - * console.log('Member joined:', member.name) - * }) - * }) - * ``` - */ -export * as Video from './video/Video' - /** @ignore */ export * from './configure' -/** - * Access the Messaging API. You can instantiate a {@link Messaging.Client} to - * send or receive SMS and MMS. Please check - * {@link Messaging.MessagingClientApiEvents} for the full list of events that - * a {@link Messaging.Client} can subscribe to. - * - * @example - * - * The following example listens for incoming SMSs over an "office" context, - * and also sends an SMS. - * - * ```javascript - * const client = new Messaging.Client({ - * project: "", - * token: "", - * contexts: ['office'] - * }) - * - * client.on('message.received', (message) => { - * console.log('message.received', message) - * }) - * - * await client.send({ - * from: '+1xxx', - * to: '+1yyy', - * body: 'Hello World!' - * }) - * ``` - */ export * as Messaging from './messaging/Messaging' export * as Chat from './chat/Chat' @@ -92,6 +11,8 @@ export * as Task from './task/Task' export * as Voice from './voice/Voice' +export * as Video from './video/Video' + /** * Access all the SignalWire APIs with a single instance. You can initiate a {@link SignalWire} to * use Messaging, Chat, PubSub, Task, Voice, and Video APIs. diff --git a/packages/realtime-api/src/types/video.ts b/packages/realtime-api/src/types/video.ts index 4678482da..882b08a2a 100644 --- a/packages/realtime-api/src/types/video.ts +++ b/packages/realtime-api/src/types/video.ts @@ -7,33 +7,845 @@ import type { RoomEnded, VideoLayoutEventNames, MemberTalkingEventNames, - Rooms, MemberUpdated, MemberUpdatedEventNames, RoomAudienceCount, VideoRoomAudienceCountEventParams, + OnRoomStarted, + OnRoomEnded, + OnRoomUpdated, + OnRoomAudienceCount, + OnRoomSubscribed, + OnMemberUpdated, + OnLayoutChanged, + OnMemberJoined, + MemberJoined, + OnMemberLeft, + MemberLeft, + OnMemberTalking, + MemberTalking, + OnMemberListUpdated, + MemberListUpdated, + PlaybackStarted, + OnPlaybackStarted, + OnPlaybackUpdated, + PlaybackUpdated, + OnPlaybackEnded, + PlaybackEnded, + OnRecordingStarted, + RecordingStarted, + OnRecordingUpdated, + RecordingUpdated, + OnRecordingEnded, + RecordingEnded, + OnStreamStarted, + OnStreamEnded, + StreamStarted, + StreamEnded, + OnMemberTalkingStarted, + MemberTalkingStarted, + OnMemberTalkingEnded, + MemberTalkingEnded, + OnMemberDeaf, + OnMemberVisible, + OnMemberAudioMuted, + OnMemberVideoMuted, + OnMemberInputVolume, + OnMemberOutputVolume, + OnMemberInputSensitivity, + VideoPlaybackEventNames, + VideoRecordingEventNames, + VideoStreamEventNames, + MemberCommandParams, + MemberCommandWithVolumeParams, + MemberCommandWithValueParams, } from '@signalwire/core' -import type { - RoomSession, - RoomSessionUpdated, - RoomSessionFullState, -} from '../video/RoomSession' +import type { RoomSession } from '../video/RoomSession' import type { RoomSessionMember, RoomSessionMemberUpdated, } from '../video/RoomSessionMember' +import { + RoomSessionPlayback, + RoomSessionPlaybackPromise, +} from '../video/RoomSessionPlayback' +import { + RoomSessionRecording, + RoomSessionRecordingPromise, +} from '../video/RoomSessionRecording' +import { + RoomSessionStream, + RoomSessionStreamPromise, +} from '../video/RoomSessionStream' +import { RoomMethods } from '../video/methods' + +/** + * Public Contract for a realtime VideoRoomSession + */ +export interface VideoRoomSessionContract { + /** Unique id for this room session */ + id: string + /** Display name for this room. Defaults to the value of `name` */ + displayName: string + /** Id of the room associated to this room session */ + roomId: string + /** @internal */ + eventChannel: string + /** Name of this room */ + name: string + /** Whether recording is active */ + recording: boolean + /** Whether muted videos are shown in the room layout. See {@link setHideVideoMuted} */ + hideVideoMuted: boolean + /** URL to the room preview. */ + previewUrl?: string + /** Current layout name used in the room. */ + layoutName: string + /** Whether the room is locked */ + locked: boolean + /** Metadata associated to this room session. */ + meta?: Record + /** Fields that have changed in this room session */ + updated?: Array> + /** Whether the room is streaming */ + streaming: boolean + + /** + * Puts the microphone on mute. The other participants will not hear audio + * from the muted participant anymore. You can use this method to mute + * either yourself or another participant in the room. + * @param params + * @param params.memberId id of the member to mute. If omitted, mutes the + * default device in the local client. + * + * @permissions + * - `room.self.audio_mute`: to mute a local device + * - `room.member.audio_mute`: to mute a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Muting your own microphone: + * ```typescript + * await room.audioMute() + * ``` + * + * @example Muting the microphone of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.audioMute({memberId: id}) + * ``` + */ + audioMute(params?: MemberCommandParams): RoomMethods.AudioMuteMember + /** + * Unmutes the microphone if it had been previously muted. You can use this + * method to unmute either yourself or another participant in the room. + * @param params + * @param params.memberId id of the member to unmute. If omitted, unmutes + * the default device in the local client. + * + * @permissions + * - `room.self.audio_unmute`: to unmute a local device + * - `room.member.audio_unmute`: to unmute a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Unmuting your own microphone: + * ```typescript + * await room.audioUnmute() + * ``` + * + * @example Unmuting the microphone of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.audioUnmute({memberId: id}) + * ``` + */ + audioUnmute(params?: MemberCommandParams): RoomMethods.AudioUnmuteMember + /** + * Puts the video on mute. Participants will see a mute image instead of the + * video stream. You can use this method to mute either yourself or another + * participant in the room. + * @param params + * @param params.memberId id of the member to mute. If omitted, mutes the + * default device in the local client. + * + * @permissions + * - `room.self.video_mute`: to unmute a local device + * - `room.member.video_mute`: to unmute a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Muting your own video: + * ```typescript + * await room.videoMute() + * ``` + * + * @example Muting the video of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.videoMute({memberId: id}) + * ``` + */ + videoMute(params?: MemberCommandParams): RoomMethods.VideoMuteMember + /** + * Unmutes the video if it had been previously muted. Participants will + * start seeing the video stream again. You can use this method to unmute + * either yourself or another participant in the room. + * @param params + * @param params.memberId id of the member to unmute. If omitted, unmutes + * the default device in the local client. + * + * @permissions + * - `room.self.video_mute`: to unmute a local device + * - `room.member.video_mute`: to unmute a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Unmuting your own video: + * ```typescript + * await room.videoUnmute() + * ``` + * + * @example Unmuting the video of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.videoUnmute({memberId: id}) + * ``` + */ + videoUnmute(params?: MemberCommandParams): RoomMethods.VideoUnmuteMember + /** @deprecated Use {@link setInputVolume} instead. */ + setMicrophoneVolume( + params: MemberCommandWithVolumeParams + ): RoomMethods.SetInputVolumeMember + /** + * Sets the input volume level (e.g. for the microphone). You can use this + * method to set the input volume for either yourself or another participant + * in the room. + * + * @param params + * @param params.memberId id of the member for which to set input volume. If + * omitted, sets the volume of the default device in the local client. + * @param params.volume desired volume. Values range from -50 to 50, with a + * default of 0. + * + * @permissions + * - `room.self.set_input_volume`: to set the volume for a local device + * - `room.member.set_input_volume`: to set the volume for a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Setting your own microphone volume: + * ```typescript + * await room.setInputVolume({volume: -10}) + * ``` + * + * @example Setting the microphone volume of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.setInputVolume({memberId: id, volume: -10}) + * ``` + */ + setInputVolume( + params: MemberCommandWithVolumeParams + ): RoomMethods.SetInputVolumeMember + /** + * Sets the input level at which the participant is identified as currently + * speaking. You can use this method to set the input sensitivity for either + * yourself or another participant in the room. + * @param params + * @param params.memberId id of the member to affect. If omitted, affects + * the default device in the local client. + * @param params.value desired sensitivity. The default value is 30 and the + * scale goes from 0 (lowest sensitivity, essentially muted) to 100 (highest + * sensitivity). + * + * @permissions + * - `room.self.set_input_sensitivity`: to set the sensitivity for a local + * device + * - `room.member.set_input_sensitivity`: to set the sensitivity for a + * remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Setting your own input sensitivity: + * ```typescript + * await room.setInputSensitivity({value: 80}) + * ``` + * + * @example Setting the input sensitivity of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.setInputSensitivity({memberId: id, value: 80}) + * ``` + */ + setInputSensitivity( + params: MemberCommandWithValueParams + ): RoomMethods.SetInputSensitivityMember + /** + * Returns a list of members currently in the room. + * + * @example + * ```typescript + * await room.getMembers() + * ``` + */ + getMembers(): RoomMethods.GetMembers + /** + * Mutes the incoming audio. The affected participant will not hear audio + * from the other participants anymore. You can use this method to make deaf + * either yourself or another participant in the room. + * + * Note that in addition to making a participant deaf, this will also + * automatically mute the microphone of the target participant (even if + * there is no `audio_mute` permission). If you want, you can then manually + * unmute it by calling {@link audioUnmute}. + * @param params + * @param params.memberId id of the member to affect. If omitted, affects + * the default device in the local client. + * + * @permissions + * - `room.self.deaf`: to make yourself deaf + * - `room.member.deaf`: to make deaf a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Making yourself deaf: + * ```typescript + * await room.deaf() + * ``` + * + * @example Making another participant deaf: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.deaf({memberId: id}) + * ``` + */ + deaf(params?: MemberCommandParams): RoomMethods.DeafMember + /** + * Unmutes the incoming audio. The affected participant will start hearing + * audio from the other participants again. You can use this method to + * undeaf either yourself or another participant in the room. + * + * Note that in addition to allowing a participants to hear the others, this + * will also automatically unmute the microphone of the target participant + * (even if there is no `audio_unmute` permission). If you want, you can then + * manually mute it by calling {@link audioMute}. + * @param params + * @param params.memberId id of the member to affect. If omitted, affects + * the default device in the local client. + * + * @permissions + * - `room.self.deaf`: to make yourself deaf + * - `room.member.deaf`: to make deaf a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Undeaf yourself: + * ```typescript + * await room.undeaf() + * ``` + * + * @example Undeaf another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.undeaf({memberId: id}) + * ``` + */ + undeaf(params?: MemberCommandParams): RoomMethods.UndeafMember + /** @deprecated Use {@link setOutputVolume} instead. */ + setSpeakerVolume( + params: MemberCommandWithVolumeParams + ): RoomMethods.SetOutputVolumeMember + /** + * Sets the output volume level (e.g., for the speaker). You can use this + * method to set the output volume for either yourself or another participant + * in the room. + * @param params + * @param params.memberId id of the member to affect. If omitted, affects the + * default device in the local client. + * @param params.volume desired volume. Values range from -50 to 50, with a + * default of 0. + * + * @permissions + * - `room.self.set_output_volume`: to set the speaker volume for yourself + * - `room.member.set_output_volume`: to set the speaker volume for a remote + * member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Setting your own output volume: + * ```typescript + * await room.setOutputVolume({volume: -10}) + * ``` + * + * @example Setting the output volume of another participant: + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.setOutputVolume({memberId: id, volume: -10}) + * ``` + */ + setOutputVolume( + params: MemberCommandWithVolumeParams + ): RoomMethods.SetOutputVolumeMember + /** + * Removes a specific participant from the room. + * @param params + * @param params.memberId id of the member to remove + * + * @permissions + * - `room.member.remove`: to remove a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * const id = 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * await room.removeMember({memberId: id}) + * ``` + */ + removeMember(params: Required): RoomMethods.RemoveMember + /** + * Removes all the participants from the room. + * + * @permissions + * - `room.member.remove`: to remove a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.removeAllMembers() + * ``` + */ + removeAllMembers(): RoomMethods.RemoveAllMembers + /** + * Show or hide muted videos in the room layout. Members that have been muted + * via {@link videoMute} will display a mute image instead of the video, if + * this setting is enabled. + * + * @param value whether to show muted videos in the room layout. + * + * @permissions + * - `room.hide_video_muted` + * - `room.show_video_muted` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await roomSession.setHideVideoMuted(false) + * ``` + */ + setHideVideoMuted(value: boolean): RoomMethods.SetHideVideoMuted + /** + * Returns a list of available layouts for the room. To set a room layout, + * use {@link setLayout}. + * + * @permissions + * - `room.list_available_layouts` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.getLayouts() + * ``` + */ + getLayouts(): RoomMethods.GetLayouts + /** + * Sets a layout for the room. You can obtain a list of available layouts + * with {@link getLayouts}. + * + * @permissions + * - `room.set_layout` + * - `room.set_position` (if you need to assign positions) + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example Set the 6x6 layout: + * ```typescript + * await room.setLayout({name: "6x6"}) + * ``` + */ + setLayout(params: RoomMethods.SetLayoutParams): RoomMethods.SetLayout + /** + * Assigns a position in the layout for multiple members. + * + * @permissions + * - `room.set_position` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```js + * await roomSession.setPositions({ + * positions: { + * "1bf4d4fb-a3e4-4d46-80a8-3ebfdceb2a60": "reserved-1", + * "e0c5be44-d6c7-438f-8cda-f859a1a0b1e7": "auto" + * } + * }) + * ``` + */ + setPositions(params: RoomMethods.SetPositionsParams): RoomMethods.SetPositions + /** + * Assigns a position in the layout to the specified member. + * + * @permissions + * - `room.self.set_position`: to set the position for the local member + * - `room.member.set_position`: to set the position for a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```js + * await roomSession.setMemberPosition({ + * memberId: "1bf4d4fb-a3e4-4d46-80a8-3ebfdceb2a60", + * position: "off-canvas" + * }) + * ``` + */ + setMemberPosition( + params: RoomMethods.SetMemberPositionParams + ): RoomMethods.SetMemberPosition + /** + * Obtains a list of recordings for the current room session. To download the + * actual mp4 file, please use the [REST + * API](https://developer.signalwire.com/apis/reference/overview). + * + * @permissions + * - `room.recording` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.getRecordings() + * ``` + * + * From your server, you can obtain the mp4 file using the [REST API](https://developer.signalwire.com/apis/reference/overview): + * ```typescript + * curl --request GET \ + * --url https://.signalwire.com/api/video/room_recordings/ \ + * --header 'Accept: application/json' \ + * --header 'Authorization: Basic ' + * ``` + */ + getRecordings(): RoomMethods.GetRecordings + /** + * Starts the recording of the room. You can use the returned + * {@link RoomSessionRecording} object to control the recording (e.g., pause, + * resume, stop). + * + * @permissions + * - `room.recording` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * const rec = await room.startRecording().onStarted() + * await rec.stop() + * ``` + */ + startRecording( + params?: RoomMethods.StartRecordingParams + ): RoomSessionRecordingPromise + /** + * Obtains a list of recordings for the current room session. + * + * @permissions + * - `room.playback` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @returns The returned objects contain all the properties of a + * {@link RoomSessionPlayback}, but no methods. + */ + getPlaybacks(): RoomMethods.GetPlaybacks + /** + * Starts a playback in the room. You can use the returned + * {@link RoomSessionPlayback} object to control the playback (e.g., pause, + * resume, setVolume and stop). + * + * @permissions + * - `room.playback` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * const playback = await roomSession.play({ url: 'rtmp://example.com/foo' }).onStarted() + * await playback.stop() + * ``` + */ + play(params: RoomMethods.PlayParams): RoomSessionPlaybackPromise + /** + * Assigns custom metadata to the RoomSession. You can use this to store + * metadata whose meaning is entirely defined by your application. + * + * Note that calling this method overwrites any metadata that had been + * previously set on this RoomSession. + * + * @param meta The medatada object to assign to the RoomSession. + * + * @permissions + * - `room.set_meta` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```js + * await roomSession.setMeta({ foo: 'bar' }) + * ``` + */ + setMeta(params: RoomMethods.SetMetaParams): RoomMethods.SetMeta + /** + * Retrieve the custom metadata for the RoomSession. + * + * @example + * ```js + * const { meta } = await roomSession.getMeta() + * ``` + */ + getMeta(): RoomMethods.GetMeta + updateMeta(params: RoomMethods.UpdateMetaParams): RoomMethods.UpdateMeta + deleteMeta(params: RoomMethods.DeleteMetaParams): RoomMethods.DeleteMeta + /** + * Assigns custom metadata to the specified RoomSession member. You can use + * this to store metadata whose meaning is entirely defined by your + * application. + * + * Note that calling this method overwrites any metadata that had been + * previously set on the specified member. + * + * @param params.memberId Id of the member to affect. If omitted, affects the + * default device in the local client. + * @param params.meta The medatada object to assign to the member. + * + * @permissions + * - `room.self.set_meta`: to set the metadata for the local member + * - `room.member.set_meta`: to set the metadata for a remote member + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * Setting metadata for the current member: + * ```js + * await roomSession.setMemberMeta({ + * meta: { + * email: 'joe@example.com' + * } + * }) + * ``` + * + * @example + * Setting metadata for another member: + * ```js + * await roomSession.setMemberMeta({ + * memberId: 'de550c0c-3fac-4efd-b06f-b5b8614b8966' // you can get this from getMembers() + * meta: { + * email: 'joe@example.com' + * } + * }) + * ``` + */ + setMemberMeta( + params: RoomMethods.SetMemberMetaParams + ): RoomMethods.SetMemberMeta + /** + * Retrieve the custom metadata for the specified RoomSession member. + * + * @param params.memberId Id of the member to retrieve the meta. If omitted, fallback to the current memberId. + * + * @example + * ```js + * const { meta } = await roomSession.getMemberMeta({ memberId: 'de550c0c-3fac-4efd-b06f-b5b8614b8966' }) + * ``` + */ + getMemberMeta(params?: MemberCommandParams): RoomMethods.GetMemberMeta + updateMemberMeta( + params: RoomMethods.UpdateMemberMetaParams + ): RoomMethods.UpdateMemberMeta + deleteMemberMeta( + params: RoomMethods.DeleteMemberMetaParams + ): RoomMethods.DeleteMemberMeta + promote(params: RoomMethods.PromoteMemberParams): RoomMethods.PromoteMember + demote(params: RoomMethods.DemoteMemberParams): RoomMethods.DemoteMember + /** + * Obtains a list of streams for the current room session. + * + * @permissions + * - `room.stream` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.getStreams() + * ``` + */ + getStreams(): RoomMethods.GetStreams + /** + * Starts to stream the room to the provided URL. You can use the returned + * {@link RoomSessionStream} object to then stop the stream. + * + * @permissions + * - `room.stream.start` or `room.stream` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * const stream = await room.startStream({ url: 'rtmp://example.com' }).onStarted() + * await stream.stop() + * ``` + */ + startStream(params: RoomMethods.StartStreamParams): RoomSessionStreamPromise + /** + * Lock the room + * + * @permissions + * - `room.lock` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.lock() + * ``` + */ + lock(): RoomMethods.Lock + /** + * Unlock the room + * + * @permissions + * - `room.unlock` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.unlock() + * ``` + */ + unlock(): RoomMethods.Unlock + /** + * Raise or lower hand of a member + * + * @permissions + * - `room.member.raisehand` and `room.member.lowerhand` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.setRaisedHand({ raised: true, memberId: '123...' }) + * ``` + */ + setRaisedHand( + params: RoomMethods.SetRaisedHandRoomParams + ): RoomMethods.SetRaisedHand + /** + * Set hand raise prioritization + * + * @permissions + * - `room.self.prioritize_handraise` + * + * You need to specify the permissions when [creating the Video Room + * Token](https://developer.signalwire.com/apis/reference/create_room_token) + * on the server side. + * + * @example + * ```typescript + * await room.setPrioritizeHandraise(true) + * ``` + */ + setPrioritizeHandraise( + prioritize: boolean + ): RoomMethods.SetPrioritizeHandraise +} -export type RealTimeVideoApiEventsHandlerMapping = Record< +/** + * RealTime Video API + */ +export type RealTimeVideoEventsHandlerMapping = Record< GlobalVideoEvents, (room: RoomSession) => void > -export type RealTimeVideoApiEvents = { - [k in keyof RealTimeVideoApiEventsHandlerMapping]: RealTimeVideoApiEventsHandlerMapping[k] +export type RealTimeVideoEvents = { + [k in keyof RealTimeVideoEventsHandlerMapping]: RealTimeVideoEventsHandlerMapping[k] +} + +export interface RealTimeVideoListeners { + onRoomStarted?: (room: RoomSession) => unknown + onRoomEnded?: (room: RoomSession) => unknown } +export type RealTimeVideoListenersEventsMapping = { + onRoomStarted: RoomStarted + onRoomEnded: RoomEnded +} + +/** + * RealTime Video Room API + */ // TODO: replace `any` with proper types. -export type RealTimeRoomApiEventsHandlerMapping = Record< +export type RealTimeRoomEventsHandlerMapping = Record< VideoLayoutEventNames, (layout: any) => void > & @@ -47,16 +859,163 @@ export type RealTimeRoomApiEventsHandlerMapping = Record< > & Record void> & Record void> & - Record void> & + Record void> & Record< RoomAudienceCount, (params: VideoRoomAudienceCountEventParams) => void > & - Record void> & - Rooms.RoomSessionRecordingEventsHandlerMapping & - Rooms.RoomSessionPlaybackEventsHandlerMapping & - Rooms.RoomSessionStreamEventsHandlerMapping + Record void> & + Record void> & + Record void> & + Record void> & + Record void> & + Record void> & + Record void> & + Record void> & + Record void> -export type RealTimeRoomApiEvents = { - [k in keyof RealTimeRoomApiEventsHandlerMapping]: RealTimeRoomApiEventsHandlerMapping[k] +export type RealTimeRoomEvents = { + [k in keyof RealTimeRoomEventsHandlerMapping]: RealTimeRoomEventsHandlerMapping[k] } + +export interface RealTimeRoomListeners { + onRoomSubscribed?: (room: RoomSession) => unknown + onRoomStarted?: (room: RoomSession) => unknown + onRoomUpdated?: (room: RoomSession) => unknown + onRoomEnded?: (room: RoomSession) => unknown + onRoomAudienceCount?: (params: VideoRoomAudienceCountEventParams) => unknown + onLayoutChanged?: (layout: any) => unknown + onMemberJoined?: (member: RoomSessionMember) => unknown + onMemberUpdated?: (member: RoomSessionMember) => unknown + onMemberListUpdated?: (member: RoomSessionMember) => unknown + onMemberLeft?: (member: RoomSessionMember) => unknown + onMemberDeaf?: (member: RoomSessionMember) => unknown + onMemberVisible?: (member: RoomSessionMember) => unknown + onMemberAudioMuted?: (member: RoomSessionMember) => unknown + onMemberVideoMuted?: (member: RoomSessionMember) => unknown + onMemberInputVolume?: (member: RoomSessionMember) => unknown + onMemberOutputVolume?: (member: RoomSessionMember) => unknown + onMemberInputSensitivity?: (member: RoomSessionMember) => unknown + onMemberTalking?: (member: RoomSessionMember) => unknown + onMemberTalkingStarted?: (member: RoomSessionMember) => unknown + onMemberTalkingEnded?: (member: RoomSessionMember) => unknown + onPlaybackStarted?: (playback: RoomSessionPlayback) => unknown + onPlaybackUpdated?: (playback: RoomSessionPlayback) => unknown + onPlaybackEnded?: (playback: RoomSessionPlayback) => unknown + onRecordingStarted?: (recording: RoomSessionRecording) => unknown + onRecordingUpdated?: (recording: RoomSessionRecording) => unknown + onRecordingEnded?: (recording: RoomSessionRecording) => unknown + onStreamStarted?: (stream: RoomSessionStream) => unknown + onStreamEnded?: (stream: RoomSessionStream) => unknown +} + +type MemberUpdatedEventMapping = { + [K in MemberUpdatedEventNames]: K +} + +export type RealtimeRoomListenersEventsMapping = Record< + OnRoomSubscribed, + RoomSubscribed +> & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record< + OnMemberAudioMuted, + MemberUpdatedEventMapping['member.updated.audioMuted'] + > & + Record< + OnMemberVideoMuted, + MemberUpdatedEventMapping['member.updated.videoMuted'] + > & + Record< + OnMemberInputVolume, + MemberUpdatedEventMapping['member.updated.inputVolume'] + > & + Record< + OnMemberOutputVolume, + MemberUpdatedEventMapping['member.updated.outputVolume'] + > & + Record< + OnMemberInputSensitivity, + MemberUpdatedEventMapping['member.updated.inputSensitivity'] + > & + Record & + Record & + Record & + Record & + Record & + Record & + Record & + Record + +/** + * RealTime Room CallPlayback API + */ +export type RealTimeRoomPlaybackEvents = Record< + VideoPlaybackEventNames, + (playback: RoomSessionPlayback) => void +> + +export interface RealTimeRoomPlaybackListeners { + onStarted?: (playback: RoomSessionPlayback) => unknown + onUpdated?: (playback: RoomSessionPlayback) => unknown + onEnded?: (playback: RoomSessionPlayback) => unknown +} + +export type RealtimeRoomPlaybackListenersEventsMapping = Record< + 'onStarted', + PlaybackStarted +> & + Record<'onUpdated', PlaybackUpdated> & + Record<'onEnded', PlaybackEnded> + +/** + * RealTime Room CallRecording API + */ +export type RealTimeRoomRecordingEvents = Record< + VideoRecordingEventNames, + (recording: RoomSessionRecording) => void +> +export interface RealTimeRoomRecordingListeners { + onStarted?: (recording: RoomSessionRecording) => unknown + onUpdated?: (recording: RoomSessionRecording) => unknown + onEnded?: (recording: RoomSessionRecording) => unknown +} + +export type RealtimeRoomRecordingListenersEventsMapping = Record< + 'onStarted', + RecordingStarted +> & + Record<'onUpdated', RecordingUpdated> & + Record<'onEnded', RecordingEnded> + +/** + * RealTime Room CallStream API + */ +export type RealTimeRoomStreamEvents = Record< + VideoStreamEventNames, + (stream: RoomSessionStream) => void +> + +export interface RealTimeRoomStreamListeners { + onStarted?: (stream: RoomSessionStream) => unknown + onEnded?: (stream: RoomSessionStream) => unknown +} + +export type RealtimeRoomStreamListenersEventsMapping = Record< + 'onStarted', + StreamStarted +> & + Record<'onEnded', StreamEnded> diff --git a/packages/realtime-api/src/types/voice.ts b/packages/realtime-api/src/types/voice.ts index 96aa7f4ba..3c3c7f384 100644 --- a/packages/realtime-api/src/types/voice.ts +++ b/packages/realtime-api/src/types/voice.ts @@ -52,8 +52,17 @@ import type { CallTap } from '../voice/CallTap' import type { CallCollect } from '../voice/CallCollect' import type { CallDetect } from '../voice/CallDetect' +/** + * Voice API + */ +export interface VoiceListeners { + onCallReceived?: (call: Call) => unknown +} + export type VoiceEvents = Record void> +export type VoiceListenersEventsMapping = Record<'onCallReceived', CallReceived> + export interface VoiceMethodsListeners { listen?: RealTimeCallListeners } @@ -66,6 +75,9 @@ export type VoiceDialPhonelMethodParams = VoiceCallDialPhoneMethodParams & export type VoiceDialSipMethodParams = VoiceCallDialSipMethodParams & VoiceMethodsListeners +/** + * Call API + */ export interface RealTimeCallListeners { onStateChanged?: (call: Call) => unknown onPlaybackStarted?: (playback: CallPlayback) => unknown diff --git a/packages/realtime-api/src/video/BaseVideo.ts b/packages/realtime-api/src/video/BaseVideo.ts new file mode 100644 index 000000000..67f4ce2d5 --- /dev/null +++ b/packages/realtime-api/src/video/BaseVideo.ts @@ -0,0 +1,80 @@ +import { + ExecuteParams, + EventEmitter, + JSONRPCSubscribeMethod, + validateEventsToSubscribe, + uuid, +} from '@signalwire/core' +import { ListenSubscriber } from '../ListenSubscriber' +import { SWClient } from '../SWClient' + +export class BaseVideo< + T extends {}, + EventTypes extends EventEmitter.ValidEventTypes +> extends ListenSubscriber { + protected subscribeMethod: JSONRPCSubscribeMethod = 'signalwire.subscribe' + protected _subscribeParams?: Record = {} + protected _eventChannel?: string = '' + + constructor(options: SWClient) { + super({ swClient: options }) + } + + protected get eventChannel() { + return this._eventChannel + } + + protected getSubscriptions() { + return validateEventsToSubscribe(this.eventNames()) + } + + protected async subscribe(listeners: T) { + const _uuid = uuid() + + // Attach listeners + this._attachListeners(listeners) + + // Subscribe to video events + await this.addEvents() + + const unsub = () => { + return new Promise(async (resolve, reject) => { + try { + // Detach listeners + this._detachListeners(listeners) + + // Remove listeners from the listener map + this.removeFromListenerMap(_uuid) + + resolve() + } catch (error) { + reject(error) + } + }) + } + + // Add listeners to the listener map + this.addToListenerMap(_uuid, { + listeners, + unsub, + }) + + return unsub + } + + protected async addEvents() { + const subscriptions = this.getSubscriptions() + + // TODO: Do not send already sent events + + const executeParams: ExecuteParams = { + method: this.subscribeMethod, + params: { + get_initial_state: true, + event_channel: this.eventChannel, + events: subscriptions, + }, + } + return this._client.execute(executeParams) + } +} diff --git a/packages/realtime-api/src/video/RoomSession.test.ts b/packages/realtime-api/src/video/RoomSession.test.ts index ef8f72c7a..31f8a4ec1 100644 --- a/packages/realtime-api/src/video/RoomSession.test.ts +++ b/packages/realtime-api/src/video/RoomSession.test.ts @@ -1,43 +1,58 @@ import { actions } from '@signalwire/core' import { configureFullStack } from '../testUtils' -import { createVideoObject } from './Video' -import { createRoomSessionObject } from './RoomSession' +import { Video } from './Video' +import { RoomSession } from './RoomSession' +import { createClient } from '../client/createClient' +import { RoomSessionRecording } from './RoomSessionRecording' +import { RoomSessionPlayback } from './RoomSessionPlayback' describe('RoomSession Object', () => { - let roomSession: ReturnType + let video: Video + let roomSession: RoomSession const roomSessionId = 'roomSessionId' - const { store, session, emitter, destroy } = configureFullStack() + const { store, destroy } = configureFullStack() + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } beforeEach(() => { - // remove all listeners before each run - emitter.removeAllListeners() + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() return new Promise(async (resolve) => { - const video = createVideoObject({ - store, - // @ts-expect-error - emitter, - }) - // @ts-expect-error - video.execute = jest.fn() + await video.listen({ + onRoomStarted: (room) => { + // @ts-expect-error + room._client.execute = jest.fn() - video.on('room.started', async (newRoom) => { - // @ts-expect-error - newRoom.execute = jest.fn() + roomSession = room - roomSession = newRoom - - resolve(roomSession) + resolve(roomSession) + }, }) - await video.subscribe() - const eventChannelOne = 'room.' const firstRoom = JSON.parse( `{"jsonrpc":"2.0","id":"uuid1","method":"signalwire.event","params":{"params":{"room":{"recording":false,"room_session_id":"${roomSessionId}","name":"First Room","hide_video_muted":false,"music_on_hold":false,"room_id":"room_id","event_channel":"${eventChannelOne}"},"room_session_id":"${roomSessionId}","room_id":"room_id","room_session":{"recording":false,"name":"First Room","hide_video_muted":false,"id":"${roomSessionId}","music_on_hold":false,"room_id":"room_id","event_channel":"${eventChannelOne}"}},"timestamp":1631692502.1308,"event_type":"video.room.started","event_channel":"video.rooms.4b7ae78a-d02e-4889-a63b-08b156d5916e"}}` ) - session.dispatch(actions.socketMessageAction(firstRoom)) + + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(firstRoom) + ) }) }) @@ -86,7 +101,7 @@ describe('RoomSession Object', () => { ] // @ts-expect-error - ;(roomSession.execute as jest.Mock).mockResolvedValueOnce({ + ;(roomSession._client.execute as jest.Mock).mockResolvedValueOnce({ recordings: recordingList, }) @@ -112,23 +127,33 @@ describe('RoomSession Object', () => { it('startRecording should return a recording object', async () => { // @ts-expect-error - roomSession.execute = jest.fn().mockResolvedValue({ - room_session_id: roomSessionId, - room_id: 'roomId', - recording: { - id: 'recordingId', - state: 'recording', + roomSession._client.execute = jest.fn().mockResolvedValue({}) + + const mockRecording = new RoomSessionRecording({ + roomSession, + payload: { + room_session_id: roomSessionId, + // @ts-expect-error + recording: { + id: 'recordingId', + state: 'recording', + }, }, }) - const recording = await roomSession.startRecording() + const recordingPromise = roomSession.startRecording() + + // @TODO: Mock server event + roomSession.emit('recording.started', mockRecording) + + const recording = await recordingPromise.onStarted() // @ts-expect-error - recording.execute = jest.fn() + recording._client.execute = jest.fn() await recording.pause() // @ts-ignore - expect(recording.execute).toHaveBeenLastCalledWith({ + expect(recording._client.execute).toHaveBeenLastCalledWith({ method: 'video.recording.pause', params: { room_session_id: roomSessionId, @@ -137,7 +162,7 @@ describe('RoomSession Object', () => { }) await recording.resume() // @ts-ignore - expect(recording.execute).toHaveBeenLastCalledWith({ + expect(recording._client.execute).toHaveBeenLastCalledWith({ method: 'video.recording.resume', params: { room_session_id: roomSessionId, @@ -146,7 +171,7 @@ describe('RoomSession Object', () => { }) await recording.stop() // @ts-ignore - expect(recording.execute).toHaveBeenLastCalledWith({ + expect(recording._client.execute).toHaveBeenLastCalledWith({ method: 'video.recording.stop', params: { room_session_id: roomSessionId, @@ -158,29 +183,39 @@ describe('RoomSession Object', () => { describe('playback apis', () => { it('play() should return a playback object', async () => { // @ts-expect-error - roomSession.execute = jest.fn().mockResolvedValue({ - room_session_id: roomSessionId, - room_id: 'roomId', - playback: { - id: 'playbackId', - state: 'playing', - url: 'rtmp://example.com/foo', - volume: 10, - started_at: 1629460916, + roomSession._client.execute = jest.fn().mockResolvedValue() + + const mockPlayback = new RoomSessionPlayback({ + roomSession, + payload: { + room_session_id: roomSessionId, + playback: { + id: 'playbackId', + state: 'playing', + url: 'rtmp://example.com/foo', + volume: 10, + // @ts-expect-error + started_at: 1629460916, + }, }, }) - const playback = await roomSession.play({ + const playbackPromise = roomSession.play({ url: 'rtmp://example.com/foo', volume: 10, }) + // @TODO: Mock server event + roomSession.emit('playback.started', mockPlayback) + + const playback = await playbackPromise.onStarted() + // @ts-expect-error - playback.execute = jest.fn() + playback._client.execute = jest.fn() await playback.pause() // @ts-ignore - expect(playback.execute).toHaveBeenLastCalledWith({ + expect(playback._client.execute).toHaveBeenLastCalledWith({ method: 'video.playback.pause', params: { room_session_id: roomSessionId, @@ -189,7 +224,7 @@ describe('RoomSession Object', () => { }) await playback.resume() // @ts-ignore - expect(playback.execute).toHaveBeenLastCalledWith({ + expect(playback._client.execute).toHaveBeenLastCalledWith({ method: 'video.playback.resume', params: { room_session_id: roomSessionId, @@ -198,7 +233,7 @@ describe('RoomSession Object', () => { }) await playback.setVolume(20) // @ts-ignore - expect(playback.execute).toHaveBeenLastCalledWith({ + expect(playback._client.execute).toHaveBeenLastCalledWith({ method: 'video.playback.set_volume', params: { room_session_id: roomSessionId, @@ -208,7 +243,7 @@ describe('RoomSession Object', () => { }) await playback.stop() // @ts-ignore - expect(playback.execute).toHaveBeenLastCalledWith({ + expect(playback._client.execute).toHaveBeenLastCalledWith({ method: 'video.playback.stop', params: { room_session_id: roomSessionId, @@ -217,26 +252,4 @@ describe('RoomSession Object', () => { }) }) }) - - describe('automatic subscribe', () => { - it('should automatically call subscribe when attaching events', async () => { - const { store, emitter, destroy } = configureFullStack() - const room = createRoomSessionObject({ - store, - // @ts-expect-error - emitter, - }) - - // @ts-expect-error - room.debouncedSubscribe = jest.fn() - - room.on('member.joined', () => {}) - room.on('member.left', () => {}) - - // @ts-expect-error - expect(room.debouncedSubscribe).toHaveBeenCalledTimes(2) - - destroy() - }) - }) }) diff --git a/packages/realtime-api/src/video/RoomSession.ts b/packages/realtime-api/src/video/RoomSession.ts index dbdb6b1ad..b334ee2f0 100644 --- a/packages/realtime-api/src/video/RoomSession.ts +++ b/packages/realtime-api/src/video/RoomSession.ts @@ -1,30 +1,35 @@ import { - BaseComponentOptionsWithPayload, - connect, extendComponent, - Rooms, - VideoRoomSessionContract, VideoRoomSessionMethods, - ConsumerContract, - EntityUpdated, - BaseConsumer, EventEmitter, - debounce, VideoRoomEventParams, Optional, validateEventsToSubscribe, + VideoMemberEntity, } from '@signalwire/core' -import { RealTimeRoomApiEvents } from '../types' +import { + RealTimeRoomEvents, + RealTimeRoomListeners, + RealtimeRoomListenersEventsMapping, + VideoRoomSessionContract, +} from '../types' import { RoomSessionMember, + RoomSessionMemberAPI, RoomSessionMemberEventParams, - createRoomSessionMemberObject, } from './RoomSessionMember' +import { RoomMethods } from './methods' +import { BaseVideo } from './BaseVideo' +import { Video } from './Video' + +export interface RoomSessionFullState extends Omit { + /** List of members that are part of this room session */ + members?: RoomSessionMember[] +} export interface RoomSession extends VideoRoomSessionContract, - ConsumerContract { - setPayload(payload: Optional): void + BaseVideo { /** * Returns a list of members currently in the room. * @@ -34,35 +39,57 @@ export interface RoomSession * ``` */ getMembers(): Promise<{ members: RoomSessionMember[] }> -} - -export type RoomSessionUpdated = EntityUpdated -export interface RoomSessionFullState extends Omit { - /** List of members that are part of this room session */ - members?: RoomSessionMember[] + /** @internal */ + setPayload(payload: Optional): void } type RoomSessionPayload = Optional -export interface RoomSessionConsumerOptions - extends BaseComponentOptionsWithPayload {} -export class RoomSessionConsumer extends BaseConsumer { - private _payload: RoomSessionPayload +export interface RoomSessionOptions { + video: Video + payload: RoomSessionPayload +} - /** @internal */ - protected subscribeParams = { - get_initial_state: true, +export class RoomSession extends BaseVideo< + RealTimeRoomListeners, + RealTimeRoomEvents +> { + private _payload: RoomSessionPayload + protected _eventMap: RealtimeRoomListenersEventsMapping = { + onRoomSubscribed: 'room.subscribed', + onRoomStarted: 'room.started', + onRoomUpdated: 'room.updated', + onRoomEnded: 'room.ended', + onRoomAudienceCount: 'room.audienceCount', + onLayoutChanged: 'layout.changed', + onMemberJoined: 'member.joined', + onMemberUpdated: 'member.updated', + onMemberLeft: 'member.left', + onMemberListUpdated: 'memberList.updated', + onMemberTalking: 'member.talking', + onMemberTalkingStarted: 'member.talking.started', + onMemberTalkingEnded: 'member.talking.ended', + onMemberDeaf: 'member.updated.deaf', + onMemberVisible: 'member.updated.visible', + onMemberAudioMuted: 'member.updated.audioMuted', + onMemberVideoMuted: 'member.updated.videoMuted', + onMemberInputVolume: 'member.updated.inputVolume', + onMemberOutputVolume: 'member.updated.outputVolume', + onMemberInputSensitivity: 'member.updated.inputSensitivity', + onPlaybackStarted: 'playback.started', + onPlaybackUpdated: 'playback.updated', + onPlaybackEnded: 'playback.ended', + onRecordingStarted: 'recording.started', + onRecordingUpdated: 'recording.updated', + onRecordingEnded: 'recording.ended', + onStreamStarted: 'stream.started', + onStreamEnded: 'stream.ended', } - /** @internal */ - private debouncedSubscribe: ReturnType - - constructor(options: RoomSessionConsumerOptions) { - super(options) + constructor(options: RoomSessionOptions) { + super(options.video._sw) this._payload = options.payload - - this.debouncedSubscribe = debounce(this.subscribe, 100) } get id() { @@ -105,6 +132,10 @@ export class RoomSessionConsumer extends BaseConsumer { return this._payload.room_session.recording } + get streaming() { + return this._payload.room_session.streaming + } + get locked() { return this._payload.room_session.locked } @@ -117,88 +148,31 @@ export class RoomSessionConsumer extends BaseConsumer { return this._payload.room_session.prioritize_handraise } + get updated() { + // TODO: Fix type issue + return this._payload.room_session + .updated as VideoRoomSessionContract['updated'] + } + /** @internal */ protected override getSubscriptions() { const eventNamesWithPrefix = this.eventNames().map( (event) => `video.${String(event)}` - ) as EventEmitter.EventNames[] + ) as EventEmitter.EventNames[] return validateEventsToSubscribe(eventNamesWithPrefix) } /** @internal */ - protected _internal_on( - event: keyof RealTimeRoomApiEvents, - fn: EventEmitter.EventListener - ) { - return super.on(event, fn) - } - - on( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.on(event, fn) - this.debouncedSubscribe() - return instance - } - - once( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.once(event, fn) - this.debouncedSubscribe() - return instance - } - - off( - event: T, - fn: EventEmitter.EventListener - ) { - const instance = super.off(event, fn) - return instance - } - - /** - * @privateRemarks - * - * Override BaseConsumer `subscribe` to resolve the promise when the 'room.subscribed' - * event comes. This way we can return to the user the room full state. - * Note: the payload will go through an EventTrasform - see the `type: roomSessionSubscribed` - * below. - */ - subscribe() { - return new Promise(async (resolve, reject) => { - const handler = (payload: RoomSessionFullState) => { - resolve(payload) - } - const subscriptions = this.getSubscriptions() - if (subscriptions.length === 0) { - this.logger.debug( - '`subscribe()` was called without any listeners attached.' - ) - return - } - - try { - super.once('room.subscribed', handler) - await super.subscribe() - } catch (error) { - super.off('room.subscribed', handler) - return reject(error) - } - }) - } - - /** @internal */ - protected setPayload(payload: Optional) { + setPayload(payload: Optional) { this._payload = payload } getMembers() { - return new Promise(async (resolve, reject) => { + return new Promise<{ + members: VideoMemberEntity[] + }>(async (resolve, reject) => { try { - const { members } = await this.execute< + const { members } = await this._client.execute< void, { members: RoomSessionMemberEventParams['member'][] } >({ @@ -210,12 +184,12 @@ export class RoomSessionConsumer extends BaseConsumer { const memberInstances: RoomSessionMember[] = [] members.forEach((member) => { - let memberInstance = this.instanceMap.get( + let memberInstance = this._client.instanceMap.get( member.id ) if (!memberInstance) { - memberInstance = createRoomSessionMemberObject({ - store: this.store, + memberInstance = new RoomSessionMemberAPI({ + roomSession: this, payload: { room_id: this.roomId, room_session_id: this.roomSessionId, @@ -228,7 +202,7 @@ export class RoomSessionConsumer extends BaseConsumer { } as RoomSessionMemberEventParams) } memberInstances.push(memberInstance) - this.instanceMap.set( + this._client.instanceMap.set( memberInstance.id, memberInstance ) @@ -243,60 +217,45 @@ export class RoomSessionConsumer extends BaseConsumer { } export const RoomSessionAPI = extendComponent< - RoomSessionConsumer, + RoomSession, Omit ->(RoomSessionConsumer, { - videoMute: Rooms.videoMuteMember, - videoUnmute: Rooms.videoUnmuteMember, - audioMute: Rooms.audioMuteMember, - audioUnmute: Rooms.audioUnmuteMember, - deaf: Rooms.deafMember, - undeaf: Rooms.undeafMember, - setInputVolume: Rooms.setInputVolumeMember, - setOutputVolume: Rooms.setOutputVolumeMember, - setMicrophoneVolume: Rooms.setInputVolumeMember, - setSpeakerVolume: Rooms.setOutputVolumeMember, - setInputSensitivity: Rooms.setInputSensitivityMember, - removeMember: Rooms.removeMember, - removeAllMembers: Rooms.removeAllMembers, - setHideVideoMuted: Rooms.setHideVideoMuted, - getLayouts: Rooms.getLayouts, - setLayout: Rooms.setLayout, - setPositions: Rooms.setPositions, - setMemberPosition: Rooms.setMemberPosition, - getRecordings: Rooms.getRecordings, - startRecording: Rooms.startRecording, - getPlaybacks: Rooms.getPlaybacks, - play: Rooms.play, - getMeta: Rooms.getMeta, - setMeta: Rooms.setMeta, - updateMeta: Rooms.updateMeta, - deleteMeta: Rooms.deleteMeta, - getMemberMeta: Rooms.getMemberMeta, - setMemberMeta: Rooms.setMemberMeta, - updateMemberMeta: Rooms.updateMemberMeta, - deleteMemberMeta: Rooms.deleteMemberMeta, - promote: Rooms.promote, - demote: Rooms.demote, - getStreams: Rooms.getStreams, - startStream: Rooms.startStream, - lock: Rooms.lock, - unlock: Rooms.unlock, - setRaisedHand: Rooms.setRaisedHand, - setPrioritizeHandraise: Rooms.setPrioritizeHandraise, +>(RoomSession, { + videoMute: RoomMethods.videoMuteMember, + videoUnmute: RoomMethods.videoUnmuteMember, + audioMute: RoomMethods.audioMuteMember, + audioUnmute: RoomMethods.audioUnmuteMember, + deaf: RoomMethods.deafMember, + undeaf: RoomMethods.undeafMember, + setInputVolume: RoomMethods.setInputVolumeMember, + setOutputVolume: RoomMethods.setOutputVolumeMember, + setMicrophoneVolume: RoomMethods.setInputVolumeMember, + setSpeakerVolume: RoomMethods.setOutputVolumeMember, + setInputSensitivity: RoomMethods.setInputSensitivityMember, + removeMember: RoomMethods.removeMember, + removeAllMembers: RoomMethods.removeAllMembers, + setHideVideoMuted: RoomMethods.setHideVideoMuted, + getLayouts: RoomMethods.getLayouts, + setLayout: RoomMethods.setLayout, + setPositions: RoomMethods.setPositions, + setMemberPosition: RoomMethods.setMemberPosition, + getRecordings: RoomMethods.getRecordings, + startRecording: RoomMethods.startRecording, + getPlaybacks: RoomMethods.getPlaybacks, + play: RoomMethods.play, + getMeta: RoomMethods.getMeta, + setMeta: RoomMethods.setMeta, + updateMeta: RoomMethods.updateMeta, + deleteMeta: RoomMethods.deleteMeta, + getMemberMeta: RoomMethods.getMemberMeta, + setMemberMeta: RoomMethods.setMemberMeta, + updateMemberMeta: RoomMethods.updateMemberMeta, + deleteMemberMeta: RoomMethods.deleteMemberMeta, + promote: RoomMethods.promote, + demote: RoomMethods.demote, + getStreams: RoomMethods.getStreams, + startStream: RoomMethods.startStream, + lock: RoomMethods.lock, + unlock: RoomMethods.unlock, + setRaisedHand: RoomMethods.setRaisedHand, + setPrioritizeHandraise: RoomMethods.setPrioritizeHandraise, }) - -export const createRoomSessionObject = ( - params: RoomSessionConsumerOptions -): RoomSession => { - const roomSession = connect< - RealTimeRoomApiEvents, - RoomSessionConsumer, - RoomSession - >({ - store: params.store, - Component: RoomSessionAPI, - })(params) - - return roomSession -} diff --git a/packages/realtime-api/src/video/RoomSessionMember.test.ts b/packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.test.ts similarity index 61% rename from packages/realtime-api/src/video/RoomSessionMember.test.ts rename to packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.test.ts index 10c52b779..2051293d8 100644 --- a/packages/realtime-api/src/video/RoomSessionMember.test.ts +++ b/packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.test.ts @@ -1,62 +1,75 @@ import { actions } from '@signalwire/core' -import { configureFullStack } from '../testUtils' -import { createRoomSessionObject } from './RoomSession' +import { configureFullStack } from '../../testUtils' +import { RoomSession, RoomSessionAPI } from '../RoomSession' import { RoomSessionMember } from './RoomSessionMember' -import { Video, createVideoObject } from './Video' +import { Video } from '../Video' +import { createClient } from '../../client/createClient' describe('Member Object', () => { - let member: RoomSessionMember let video: Video + let roomSession: RoomSession + let member: RoomSessionMember const roomSessionId = '3b36a747-e33a-409d-bbb9-1ddffc543b6d' const memberId = '483c60ba-b776-4051-834a-5575c4b7cffe' - const { store, session, emitter, destroy } = configureFullStack() + const { store, destroy } = configureFullStack() - beforeEach(() => { - // remove all listeners before each run - emitter.removeAllListeners() + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } - video = createVideoObject({ - store, - // @ts-expect-error - emitter, - }) + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } // @ts-expect-error - video.execute = jest.fn() + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() return new Promise(async (resolve) => { - const roomSession = createRoomSessionObject({ - store, - emitter, + roomSession = new RoomSessionAPI({ + video, payload: { - // @ts-expect-error room_session: { id: roomSessionId, event_channel: 'room.e4b8baff-865d-424b-a210-4a182a3b1451', }, }, }) - store.instanceMap.set(roomSessionId, roomSession) - roomSession.on('member.joined', (newMember) => { - // @ts-expect-error - newMember.execute = jest.fn() - member = newMember - resolve(member) - }) // @ts-expect-error - roomSession.execute = jest.fn() - roomSession.subscribe().then(() => { - // Trigger a member.joined event to resolve the main Promise - const memberJoinedEvent = JSON.parse( - `{"jsonrpc":"2.0","id":"uuid","method":"signalwire.event","params":{"params":{"room_session_id":"${roomSessionId}","room_id":"03b71e19-1ed2-4417-a544-7d0ca01186ed","member":{"visible":false,"room_session_id":"${roomSessionId}","input_volume":0,"id":"${memberId}","input_sensitivity":44,"audio_muted":false,"output_volume":0,"name":"edoardo","deaf":false,"video_muted":false,"room_id":"03b71e19-1ed2-4417-a544-7d0ca01186ed","type":"member"}},"timestamp":1234,"event_type":"video.member.joined","event_channel":"${roomSession.eventChannel}"}}` - ) - session.dispatch(actions.socketMessageAction(memberJoinedEvent)) + roomSession._client.store.instanceMap.set(roomSessionId, roomSession) + // @ts-expect-error + roomSession._client.execute = jest.fn() + + await roomSession.listen({ + onMemberJoined: (newMember) => { + member = newMember + resolve(member) + }, }) + const memberJoinedEvent = JSON.parse( + `{"jsonrpc":"2.0","id":"uuid","method":"signalwire.event","params":{"params":{"room_session_id":"${roomSessionId}","room_id":"03b71e19-1ed2-4417-a544-7d0ca01186ed","member":{"visible":false,"room_session_id":"${roomSessionId}","input_volume":0,"id":"${memberId}","input_sensitivity":44,"audio_muted":false,"output_volume":0,"name":"edoardo","deaf":false,"video_muted":false,"room_id":"03b71e19-1ed2-4417-a544-7d0ca01186ed","type":"member"}},"timestamp":1234,"event_type":"video.member.joined","event_channel":"${roomSession.eventChannel}"}}` + ) + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(memberJoinedEvent) + ) + // Emit room.subscribed event to resolve the promise above. const roomSubscribedEvent = JSON.parse( `{"jsonrpc":"2.0","id":"4198ee12-ec98-4002-afc5-e031fc32bb8a","method":"signalwire.event","params":{"params":{"room_session":{"recording":false,"name":"behindTheWire","hide_video_muted":false,"id":"${roomSessionId}","members":[{"visible":false,"room_session_id":"${roomSessionId}","input_volume":0,"id":"b3b0cfd6-2382-4ac6-a8c9-9182584697ae","input_sensitivity":44,"audio_muted":false,"output_volume":0,"name":"edoardo","deaf":false,"video_muted":false,"room_id":"297ec3bb-fdc5-4995-ae75-c40a43c272ee","type":"member"}],"room_id":"297ec3bb-fdc5-4995-ae75-c40a43c272ee","event_channel":"${roomSession.eventChannel}"}},"timestamp":1632738590.6955,"event_type":"video.room.subscribed","event_channel":"${roomSession.eventChannel}"}}` ) - session.dispatch(actions.socketMessageAction(roomSubscribedEvent)) + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(roomSubscribedEvent) + ) }) }) @@ -66,11 +79,11 @@ describe('Member Object', () => { const expectExecute = (payload: any) => { // @ts-expect-error - expect(member.execute).toHaveBeenLastCalledWith(payload, { + expect(member._client.execute).toHaveBeenLastCalledWith(payload, { transformResolve: expect.anything(), }) // @ts-expect-error - member.execute.mockClear() + member._client.execute.mockClear() } it('should have all the custom methods defined', async () => { @@ -149,42 +162,15 @@ describe('Member Object', () => { value: 10, }, }) + await member.remove() // @ts-expect-error - expect(member.execute).toHaveBeenLastCalledWith({ + expect(member._client.execute).toHaveBeenLastCalledWith({ method: 'video.member.remove', params: { room_session_id: member.roomSessionId, member_id: member.id, }, }) - await member.setRaisedHand() - // @ts-expect-error - expect(member.execute).toHaveBeenLastCalledWith( - { - method: 'video.member.raisehand', - params: { - room_session_id: member.roomSessionId, - member_id: member.id, - }, - }, - { - transformResolve: expect.anything(), - } - ) - await member.setRaisedHand({ raised: false }) - // @ts-expect-error - expect(member.execute).toHaveBeenLastCalledWith( - { - method: 'video.member.lowerhand', - params: { - room_session_id: member.roomSessionId, - member_id: member.id, - }, - }, - { - transformResolve: expect.anything(), - } - ) }) }) diff --git a/packages/realtime-api/src/video/RoomSessionMember.ts b/packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.ts similarity index 65% rename from packages/realtime-api/src/video/RoomSessionMember.ts rename to packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.ts index 1ab00777e..8a74e8c88 100644 --- a/packages/realtime-api/src/video/RoomSessionMember.ts +++ b/packages/realtime-api/src/video/RoomSessionMember/RoomSessionMember.ts @@ -1,9 +1,5 @@ import { - connect, - BaseComponent, - BaseComponentOptionsWithPayload, extendComponent, - Rooms, VideoMemberContract, VideoMemberMethods, EntityUpdated, @@ -12,6 +8,9 @@ import { VideoMemberUpdatedEventParams, VideoMemberTalkingEventParams, } from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { RoomMethods } from '../methods' +import type { Client } from '../../client/Client' /** * Represents a member of a room session. You receive instances of this type by @@ -37,17 +36,17 @@ export type RoomSessionMemberEventParams = ) & VideoMemberTalkingEventParams -export interface RoomSessionMemberOptions - extends BaseComponentOptionsWithPayload {} +export interface RoomSessionOptions { + roomSession: RoomSession + payload: RoomSessionMemberEventParams +} -// TODO: Extend from a variant of `BaseComponent` that -// doesn't expose EventEmitter methods -class RoomSessionMemberComponent extends BaseComponent<{}> { +export class RoomSessionMember { + private _client: Client private _payload: RoomSessionMemberEventParams - constructor(options: RoomSessionMemberOptions) { - super(options) - + constructor(options: RoomSessionOptions) { + this._client = options.roomSession._sw.client this._payload = options.payload } @@ -124,7 +123,7 @@ class RoomSessionMemberComponent extends BaseComponent<{}> { } /** @internal */ - protected setPayload(payload: RoomSessionMemberEventParams) { + setPayload(payload: RoomSessionMemberEventParams) { // Reshape the payload since the `video.member.talking` event does not return all the parameters of a member const newPayload = { ...payload, @@ -137,41 +136,30 @@ class RoomSessionMemberComponent extends BaseComponent<{}> { } async remove() { - await this.execute({ + await this._client.execute({ method: 'video.member.remove', params: { - room_session_id: this.getStateProperty('roomSessionId'), - member_id: this.getStateProperty('memberId'), + room_session_id: this.roomSessionId, + member_id: this.memberId, }, }) } } -const RoomSessionMemberAPI = extendComponent< - RoomSessionMemberComponent, - // `remove` is defined by `RoomSessionMemberComponent` +export const RoomSessionMemberAPI = extendComponent< + RoomSessionMember, + // `remove` is defined by `RoomSessionMember` Omit ->(RoomSessionMemberComponent, { - audioMute: Rooms.audioMuteMember, - audioUnmute: Rooms.audioUnmuteMember, - videoMute: Rooms.videoMuteMember, - videoUnmute: Rooms.videoUnmuteMember, - setDeaf: Rooms.setDeaf, - setMicrophoneVolume: Rooms.setInputVolumeMember, - setInputVolume: Rooms.setInputVolumeMember, - setSpeakerVolume: Rooms.setOutputVolumeMember, - setOutputVolume: Rooms.setOutputVolumeMember, - setInputSensitivity: Rooms.setInputSensitivityMember, - setRaisedHand: Rooms.setRaisedHand, +>(RoomSessionMember, { + audioMute: RoomMethods.audioMuteMember, + audioUnmute: RoomMethods.audioUnmuteMember, + videoMute: RoomMethods.videoMuteMember, + videoUnmute: RoomMethods.videoUnmuteMember, + setDeaf: RoomMethods.setDeaf, + setMicrophoneVolume: RoomMethods.setInputVolumeMember, + setInputVolume: RoomMethods.setInputVolumeMember, + setSpeakerVolume: RoomMethods.setOutputVolumeMember, + setOutputVolume: RoomMethods.setOutputVolumeMember, + setInputSensitivity: RoomMethods.setInputSensitivityMember, + setRaisedHand: RoomMethods.setRaisedHand, }) - -export const createRoomSessionMemberObject = ( - params: RoomSessionMemberOptions -): RoomSessionMember => { - const member = connect<{}, RoomSessionMemberComponent, RoomSessionMember>({ - store: params.store, - Component: RoomSessionMemberAPI, - })(params) - - return member -} diff --git a/packages/realtime-api/src/video/RoomSessionMember/index.ts b/packages/realtime-api/src/video/RoomSessionMember/index.ts new file mode 100644 index 000000000..bedee71bd --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionMember/index.ts @@ -0,0 +1 @@ +export * from './RoomSessionMember' diff --git a/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.test.ts b/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.test.ts new file mode 100644 index 000000000..cc351043b --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.test.ts @@ -0,0 +1,213 @@ +import { EventEmitter } from '@signalwire/core' +import { configureFullStack } from '../../testUtils' +import { createClient } from '../../client/createClient' +import { Video } from '../Video' +import { RoomSessionAPI, RoomSession } from '../RoomSession' +import { RoomSessionPlayback } from './RoomSessionPlayback' +import { + decoratePlaybackPromise, + methods, + getters, +} from './decoratePlaybackPromise' + +describe('RoomSessionPlayback', () => { + let video: Video + let roomSession: RoomSession + let playback: RoomSessionPlayback + + const roomSessionId = 'room-session-id' + const { store, destroy } = configureFullStack() + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } + + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() + + roomSession = new RoomSessionAPI({ + video, + payload: { + room_session: { + id: roomSessionId, + event_channel: 'room.e4b8baff-865d-424b-a210-4a182a3b1451', + }, + }, + }) + + playback = new RoomSessionPlayback({ + payload: { + //@ts-expect-error + playback: { + id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + room_session_id: roomSessionId, + }, + roomSession, + }) + // @ts-expect-error + playback._client.execute = jest.fn() + }) + + afterAll(() => { + destroy() + }) + + it('should have an event emitter', () => { + expect(playback['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'playback.started', + onUpdated: 'playback.updated', + onEnded: 'playback.ended', + } + expect(playback['_eventMap']).toEqual(expectedEventMap) + }) + + it('should control an active playback', async () => { + const baseExecuteParams = { + method: '', + params: { + room_session_id: roomSessionId, + playback_id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + } + await playback.pause() + // @ts-expect-error + expect(playback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.playback.pause', + }) + await playback.resume() + // @ts-expect-error + expect(playback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.playback.resume', + }) + await playback.stop() + // @ts-expect-error + expect(playback._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.playback.stop', + }) + await playback.setVolume(30) + // @ts-expect-error + expect(playback._client.execute).toHaveBeenLastCalledWith({ + method: 'video.playback.set_volume', + params: { + room_session_id: roomSessionId, + playback_id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + volume: 30, + }, + }) + }) + + it('should throw an error on methods if playback has ended', async () => { + playback.setPayload({ + // @ts-expect-error + playback: { + state: 'completed', + }, + }) + + await expect(playback.pause()).rejects.toThrowError('Action has ended') + await expect(playback.resume()).rejects.toThrowError('Action has ended') + await expect(playback.stop()).rejects.toThrowError('Action has ended') + await expect(playback.setVolume(1)).rejects.toThrowError('Action has ended') + await expect(playback.seek(1)).rejects.toThrowError('Action has ended') + await expect(playback.forward(1)).rejects.toThrowError('Action has ended') + await expect(playback.rewind(1)).rejects.toThrowError('Action has ended') + }) + + describe('decoratePlaybackPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(playback) + + const decoratedPromise = decoratePlaybackPromise.call( + roomSession, + innerPromise + ) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(playback) + + const decoratedPromise = decoratePlaybackPromise.call( + roomSession, + innerPromise + ) + + // Simulate the playback ended event + roomSession.emit('playback.ended', playback) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when playback ends', async () => { + const innerPromise = Promise.resolve(playback) + + const decoratedPromise = decoratePlaybackPromise.call( + roomSession, + innerPromise + ) + + // Simulate the playback ended event + roomSession.emit('playback.ended', playback) + + await expect(decoratedPromise).resolves.toEqual( + expect.any(RoomSessionPlayback) + ) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decoratePlaybackPromise.call( + roomSession, + innerPromise + ) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.ts b/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.ts new file mode 100644 index 000000000..5eb72365d --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionPlayback/RoomSessionPlayback.ts @@ -0,0 +1,217 @@ +/** + * Once we have new interface for Browser SDK; + * RoomSessionPlayback in core should be removed + * RoomSessionPlayback in realtime-api should be moved to core + */ + +import type { + VideoPlaybackContract, + VideoPlaybackEventParams, +} from '@signalwire/core' +import { ListenSubscriber } from '../../ListenSubscriber' +import { + RealTimeRoomPlaybackEvents, + RealTimeRoomPlaybackListeners, + RealtimeRoomPlaybackListenersEventsMapping, +} from '../../types' +import { RoomSession } from '../RoomSession' + +/** + * Instances of this class allow you to control (e.g., pause, resume, stop) the + * playback inside a room session. You can obtain instances of this class by + * starting a playback from the desired {@link RoomSession} (see + * {@link RoomSession.play}) + */ + +export interface RoomSessionPlaybackOptions { + roomSession: RoomSession + payload: VideoPlaybackEventParams +} + +export class RoomSessionPlayback + extends ListenSubscriber< + RealTimeRoomPlaybackListeners, + RealTimeRoomPlaybackEvents + > + implements VideoPlaybackContract +{ + private _payload: VideoPlaybackEventParams + protected _eventMap: RealtimeRoomPlaybackListenersEventsMapping = { + onStarted: 'playback.started', + onUpdated: 'playback.updated', + onEnded: 'playback.ended', + } + + constructor(options: RoomSessionPlaybackOptions) { + super({ swClient: options.roomSession._sw }) + + this._payload = options.payload + } + + get id() { + return this._payload.playback.id + } + + get roomId() { + return this._payload.room_id + } + + get roomSessionId() { + return this._payload.room_session_id + } + + get url() { + return this._payload.playback.url + } + + get state() { + return this._payload.playback.state + } + + get volume() { + return this._payload.playback.volume + } + + get startedAt() { + if (!this._payload.playback.started_at) return undefined + return new Date( + (this._payload.playback.started_at as unknown as number) * 1000 + ) + } + + get endedAt() { + if (!this._payload.playback.ended_at) return undefined + return new Date( + (this._payload.playback.ended_at as unknown as number) * 1000 + ) + } + + get position() { + return this._payload.playback.position + } + + get seekable() { + return this._payload.playback.seekable + } + + get hasEnded() { + if (this.state === 'completed') { + return true + } + return false + } + + /** @internal */ + setPayload(payload: VideoPlaybackEventParams) { + this._payload = payload + } + + /** @internal */ + attachListeners(listeners?: RealTimeRoomPlaybackListeners) { + if (listeners) { + this.listen(listeners) + } + } + + async pause() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.pause', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + }, + }) + } + + async resume() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.resume', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + }, + }) + } + + async stop() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.stop', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + }, + }) + } + + async setVolume(volume: number) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.set_volume', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + volume, + }, + }) + } + + async seek(timecode: number) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.seek_absolute', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + position: Math.abs(timecode), + }, + }) + } + + async forward(offset: number = 5000) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.seek_relative', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + position: Math.abs(offset), + }, + }) + } + + async rewind(offset: number = 5000) { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.playback.seek_relative', + params: { + room_session_id: this.roomSessionId, + playback_id: this.id, + position: -Math.abs(offset), + }, + }) + } +} diff --git a/packages/realtime-api/src/video/RoomSessionPlayback/decoratePlaybackPromise.ts b/packages/realtime-api/src/video/RoomSessionPlayback/decoratePlaybackPromise.ts new file mode 100644 index 000000000..210858b2d --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionPlayback/decoratePlaybackPromise.ts @@ -0,0 +1,71 @@ +import { Promisify } from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { RoomSessionPlayback } from './RoomSessionPlayback' +import { decoratePromise } from '../../decoratePromise' +import { RealTimeRoomPlaybackListeners } from '../../types' + +export interface RoomSessionPlaybackEnded { + id: string + roomId: string + roomSessionId: string + url: string + state: RoomSessionPlayback['state'] + volume: number + startedAt?: Date + endedAt?: Date + position: number + seekable: boolean +} + +export interface RoomSessionPlaybackPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: ( + listeners: RealTimeRoomPlaybackListeners + ) => Promise<() => Promise> + pause: () => Promise + resume: () => Promise + stop: () => Promise + setVolume: (volume: number) => Promise + seek: (timecode: number) => Promise + forward: (offset: number) => Promise + rewind: (offset: number) => Promise +} + +export const getters = [ + 'id', + 'roomId', + 'roomSessionId', + 'url', + 'state', + 'volume', + 'startedAt', + 'endedAt', + 'position', + 'seekable', +] + +export const methods = [ + 'pause', + 'resume', + 'stop', + 'setVolume', + 'seek', + 'forward', + 'rewind', +] + +export function decoratePlaybackPromise( + this: RoomSession, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'playback', + methods, + getters, + }) as RoomSessionPlaybackPromise +} diff --git a/packages/realtime-api/src/video/RoomSessionPlayback/index.ts b/packages/realtime-api/src/video/RoomSessionPlayback/index.ts new file mode 100644 index 000000000..05973e5b0 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionPlayback/index.ts @@ -0,0 +1,3 @@ +export * from './RoomSessionPlayback' +export * from './decoratePlaybackPromise' +export { decoratePlaybackPromise } from './decoratePlaybackPromise' diff --git a/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.test.ts b/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.test.ts new file mode 100644 index 000000000..1fecc3828 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.test.ts @@ -0,0 +1,199 @@ +import { EventEmitter } from '@signalwire/core' +import { configureFullStack } from '../../testUtils' +import { createClient } from '../../client/createClient' +import { Video } from '../Video' +import { RoomSessionAPI, RoomSession } from '../RoomSession' +import { RoomSessionRecording } from './RoomSessionRecording' +import { + decorateRecordingPromise, + methods, + getters, +} from './decorateRecordingPromise' + +describe('RoomSessionRecording', () => { + let video: Video + let roomSession: RoomSession + let recording: RoomSessionRecording + + const roomSessionId = 'room-session-id' + const { store, destroy } = configureFullStack() + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } + + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() + + roomSession = new RoomSessionAPI({ + video, + payload: { + room_session: { + id: roomSessionId, + event_channel: 'room.e4b8baff-865d-424b-a210-4a182a3b1451', + }, + }, + }) + + recording = new RoomSessionRecording({ + payload: { + //@ts-expect-error + recording: { + id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + room_session_id: roomSessionId, + }, + roomSession, + }) + // @ts-expect-error + recording._client.execute = jest.fn() + }) + + afterAll(() => { + destroy() + }) + + it('should have an event emitter', () => { + expect(recording['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'recording.started', + onUpdated: 'recording.updated', + onEnded: 'recording.ended', + } + expect(recording['_eventMap']).toEqual(expectedEventMap) + }) + + it('should control an active recording', async () => { + const baseExecuteParams = { + method: '', + params: { + room_session_id: 'room-session-id', + recording_id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + } + await recording.pause() + // @ts-expect-error + expect(recording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.recording.pause', + }) + await recording.resume() + // @ts-expect-error + expect(recording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.recording.resume', + }) + await recording.stop() + // @ts-expect-error + expect(recording._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.recording.stop', + }) + }) + + it('should throw an error on methods if recording has ended', async () => { + recording.setPayload({ + // @ts-expect-error + recording: { + state: 'completed', + }, + }) + + await expect(recording.pause()).rejects.toThrowError('Action has ended') + await expect(recording.resume()).rejects.toThrowError('Action has ended') + await expect(recording.stop()).rejects.toThrowError('Action has ended') + }) + + describe('decorateRecordingPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(recording) + + const decoratedPromise = decorateRecordingPromise.call( + roomSession, + innerPromise + ) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(recording) + + const decoratedPromise = decorateRecordingPromise.call( + roomSession, + innerPromise + ) + + // Simulate the recording ended event + roomSession.emit('recording.ended', recording) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when recording ends', async () => { + const innerPromise = Promise.resolve(recording) + + const decoratedPromise = decorateRecordingPromise.call( + roomSession, + innerPromise + ) + + // Simulate the recording ended event + roomSession.emit('recording.ended', recording) + + await expect(decoratedPromise).resolves.toEqual( + expect.any(RoomSessionRecording) + ) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decorateRecordingPromise.call( + roomSession, + innerPromise + ) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.ts b/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.ts new file mode 100644 index 000000000..3af7a92db --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionRecording/RoomSessionRecording.ts @@ -0,0 +1,138 @@ +/** + * Once we have new interface for Browser SDK; + * RoomSessionRecording in core should be removed + * RoomSessionRecording in realtime-api should be moved to core + */ + +import type { + VideoRecordingEventParams, + VideoRecordingMethods, +} from '@signalwire/core' +import { + RealTimeRoomRecordingEvents, + RealTimeRoomRecordingListeners, + RealtimeRoomRecordingListenersEventsMapping, +} from '../../types' +import { ListenSubscriber } from '../../ListenSubscriber' +import { RoomSession } from '../RoomSession' + +export interface RoomSessionRecordingOptions { + roomSession: RoomSession + payload: VideoRecordingEventParams +} + +export class RoomSessionRecording + extends ListenSubscriber< + RealTimeRoomRecordingListeners, + RealTimeRoomRecordingEvents + > + implements VideoRecordingMethods +{ + private _payload: VideoRecordingEventParams + protected _eventMap: RealtimeRoomRecordingListenersEventsMapping = { + onStarted: 'recording.started', + onUpdated: 'recording.updated', + onEnded: 'recording.ended', + } + + constructor(options: RoomSessionRecordingOptions) { + super({ swClient: options.roomSession._sw }) + + this._payload = options.payload + } + + get id() { + return this._payload.recording.id + } + + get roomId() { + return this._payload.room_id + } + + get roomSessionId() { + return this._payload.room_session_id + } + + get state() { + return this._payload.recording.state + } + + get duration() { + return this._payload.recording.duration + } + + get startedAt() { + if (!this._payload.recording.started_at) return undefined + return new Date( + (this._payload.recording.started_at as unknown as number) * 1000 + ) + } + + get endedAt() { + if (!this._payload.recording.ended_at) return undefined + return new Date( + (this._payload.recording.ended_at as unknown as number) * 1000 + ) + } + + get hasEnded() { + if (this.state === 'completed') { + return true + } + return false + } + + /** @internal */ + setPayload(payload: VideoRecordingEventParams) { + this._payload = payload + } + + /** @internal */ + attachListeners(listeners?: RealTimeRoomRecordingListeners) { + if (listeners) { + this.listen(listeners) + } + } + + async pause() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.recording.pause', + params: { + room_session_id: this.roomSessionId, + recording_id: this.id, + }, + }) + } + + async resume() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.recording.resume', + params: { + room_session_id: this.roomSessionId, + recording_id: this.id, + }, + }) + } + + async stop() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.recording.stop', + params: { + room_session_id: this.roomSessionId, + recording_id: this.id, + }, + }) + } +} diff --git a/packages/realtime-api/src/video/RoomSessionRecording/decorateRecordingPromise.ts b/packages/realtime-api/src/video/RoomSessionRecording/decorateRecordingPromise.ts new file mode 100644 index 000000000..f996fe1c5 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionRecording/decorateRecordingPromise.ts @@ -0,0 +1,53 @@ +import { Promisify } from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { RoomSessionRecording } from './RoomSessionRecording' +import { decoratePromise } from '../../decoratePromise' +import { RealTimeRoomRecordingListeners } from '../../types' + +export interface RoomSessionRecordingEnded { + id: string + roomId: string + roomSessionId: string + state: RoomSessionRecording['state'] + duration?: number + startedAt?: Date + endedAt?: Date +} + +export interface RoomSessionRecordingPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: ( + listeners: RealTimeRoomRecordingListeners + ) => Promise<() => Promise> + pause: () => Promise + resume: () => Promise + stop: () => Promise +} + +export const getters = [ + 'id', + 'roomId', + 'roomSessionId', + 'state', + 'duration', + 'startedAt', + 'endedAt', +] + +export const methods = ['pause', 'resume', 'stop'] + +export function decorateRecordingPromise( + this: RoomSession, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'recording', + methods, + getters, + }) as RoomSessionRecordingPromise +} diff --git a/packages/realtime-api/src/video/RoomSessionRecording/index.ts b/packages/realtime-api/src/video/RoomSessionRecording/index.ts new file mode 100644 index 000000000..5d16d6db4 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionRecording/index.ts @@ -0,0 +1,3 @@ +export * from './RoomSessionRecording' +export * from './decorateRecordingPromise' +export { decorateRecordingPromise } from './decorateRecordingPromise' diff --git a/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.test.ts b/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.test.ts new file mode 100644 index 000000000..65825b96d --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.test.ts @@ -0,0 +1,185 @@ +import { EventEmitter } from '@signalwire/core' +import { configureFullStack } from '../../testUtils' +import { createClient } from '../../client/createClient' +import { Video } from '../Video' +import { RoomSessionAPI, RoomSession } from '../RoomSession' +import { RoomSessionStream } from './RoomSessionStream' +import { + decorateStreamPromise, + getters, + methods, +} from './decorateStreamPromise' + +describe('RoomSessionStream', () => { + let video: Video + let roomSession: RoomSession + let stream: RoomSessionStream + + const roomSessionId = 'room-session-id' + const { store, destroy } = configureFullStack() + + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } + + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() + // @ts-expect-error + video._client.runWorker = jest.fn() + + roomSession = new RoomSessionAPI({ + video, + payload: { + room_session: { + id: roomSessionId, + event_channel: 'room.e4b8baff-865d-424b-a210-4a182a3b1451', + }, + }, + }) + + stream = new RoomSessionStream({ + payload: { + // @ts-expect-error + stream: { + id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + room_session_id: roomSessionId, + }, + roomSession, + }) + // @ts-expect-error + stream._client.execute = jest.fn() + }) + + afterAll(() => { + destroy() + }) + + it('should have an event emitter', () => { + expect(stream['emitter']).toBeInstanceOf(EventEmitter) + }) + + it('should declare the correct event map', () => { + const expectedEventMap = { + onStarted: 'stream.started', + onEnded: 'stream.ended', + } + expect(stream['_eventMap']).toEqual(expectedEventMap) + }) + + it('should control an active stream', async () => { + const baseExecuteParams = { + method: '', + params: { + room_session_id: 'room-session-id', + stream_id: 'c22d7223-5a01-49fe-8da0-46bec8e75e32', + }, + } + + await stream.stop() + // @ts-expect-error + expect(stream._client.execute).toHaveBeenLastCalledWith({ + ...baseExecuteParams, + method: 'video.stream.stop', + }) + }) + + it('should throw an error on methods if stream has ended', async () => { + stream.setPayload({ + // @ts-expect-error + stream: { + state: 'completed', + }, + }) + + await expect(stream.stop()).rejects.toThrowError('Action has ended') + }) + + describe('decorateStreamPromise', () => { + it('expose correct properties before resolve', () => { + const innerPromise = Promise.resolve(stream) + + const decoratedPromise = decorateStreamPromise.call( + roomSession, + innerPromise + ) + + expect(decoratedPromise).toHaveProperty('onStarted', expect.any(Function)) + expect(decoratedPromise.onStarted()).toBeInstanceOf(Promise) + expect(decoratedPromise).toHaveProperty('onEnded', expect.any(Function)) + expect(decoratedPromise.onEnded()).toBeInstanceOf(Promise) + methods.forEach((method) => { + expect(decoratedPromise).toHaveProperty(method, expect.any(Function)) + // @ts-expect-error + expect(decoratedPromise[method]()).toBeInstanceOf(Promise) + }) + getters.forEach((getter) => { + expect(decoratedPromise).toHaveProperty(getter) + // @ts-expect-error + expect(decoratedPromise[getter]).toBeInstanceOf(Promise) + }) + }) + + it('expose correct properties after resolve', async () => { + const innerPromise = Promise.resolve(stream) + + const decoratedPromise = decorateStreamPromise.call( + roomSession, + innerPromise + ) + + // Simulate the stream ended event + roomSession.emit('stream.ended', stream) + + const ended = await decoratedPromise + + expect(ended).not.toHaveProperty('onStarted', expect.any(Function)) + expect(ended).not.toHaveProperty('onEnded', expect.any(Function)) + methods.forEach((method) => { + expect(ended).toHaveProperty(method, expect.any(Function)) + }) + getters.forEach((getter) => { + expect(ended).toHaveProperty(getter) + // @ts-expect-error + expect(ended[getter]).not.toBeInstanceOf(Promise) + }) + }) + + it('resolves when stream ends', async () => { + const innerPromise = Promise.resolve(stream) + + const decoratedPromise = decorateStreamPromise.call( + roomSession, + innerPromise + ) + + // Simulate the stream ended event + roomSession.emit('stream.ended', stream) + + await expect(decoratedPromise).resolves.toEqual( + expect.any(RoomSessionStream) + ) + }) + + it('rejects on inner promise rejection', async () => { + const innerPromise = Promise.reject(new Error('Recording failed')) + + const decoratedPromise = decorateStreamPromise.call( + roomSession, + innerPromise + ) + + await expect(decoratedPromise).rejects.toThrow('Recording failed') + }) + }) +}) diff --git a/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.ts b/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.ts new file mode 100644 index 000000000..1046f8399 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionStream/RoomSessionStream.ts @@ -0,0 +1,111 @@ +/** + * Once we have new interface for Browser SDK; + * RoomSessionStream in core should be removed + * RoomSessionStream in realtime-api should be moved to core + */ + +import type { + VideoStreamEventParams, + VideoStreamMethods, +} from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { + RealTimeRoomStreamEvents, + RealTimeRoomStreamListeners, + RealtimeRoomStreamListenersEventsMapping, +} from '../../types' +import { ListenSubscriber } from '../../ListenSubscriber' + +export interface RoomSessionStreamOptions { + roomSession: RoomSession + payload: VideoStreamEventParams +} + +export class RoomSessionStream + extends ListenSubscriber< + RealTimeRoomStreamListeners, + RealTimeRoomStreamEvents + > + implements VideoStreamMethods +{ + private _payload: VideoStreamEventParams + protected _eventMap: RealtimeRoomStreamListenersEventsMapping = { + onStarted: 'stream.started', + onEnded: 'stream.ended', + } + + constructor(options: RoomSessionStreamOptions) { + super({ swClient: options.roomSession._sw }) + + this._payload = options.payload + } + + get id() { + return this._payload.stream.id + } + + get roomId() { + return this._payload.room_id + } + + get roomSessionId() { + return this._payload.room_session_id + } + + get state() { + return this._payload.stream.state + } + + get duration() { + return this._payload.stream.duration + } + + get url() { + return this._payload.stream.url + } + + get startedAt() { + if (!this._payload.stream.started_at) return undefined + return new Date( + (this._payload.stream.started_at as unknown as number) * 1000 + ) + } + + get endedAt() { + if (!this._payload.stream.ended_at) return undefined + return new Date((this._payload.stream.ended_at as unknown as number) * 1000) + } + + get hasEnded() { + if (this.state === 'completed') { + return true + } + return false + } + + /** @internal */ + setPayload(payload: VideoStreamEventParams) { + this._payload = payload + } + + /** @internal */ + attachListeners(listeners?: RealTimeRoomStreamListeners) { + if (listeners) { + this.listen(listeners) + } + } + + async stop() { + if (this.hasEnded) { + throw new Error('Action has ended') + } + + await this._client.execute({ + method: 'video.stream.stop', + params: { + room_session_id: this.roomSessionId, + stream_id: this.id, + }, + }) + } +} diff --git a/packages/realtime-api/src/video/RoomSessionStream/decorateStreamPromise.ts b/packages/realtime-api/src/video/RoomSessionStream/decorateStreamPromise.ts new file mode 100644 index 000000000..794c9513d --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionStream/decorateStreamPromise.ts @@ -0,0 +1,53 @@ +import { Promisify } from '@signalwire/core' +import { RoomSession } from '../RoomSession' +import { RoomSessionStream } from './RoomSessionStream' +import { decoratePromise } from '../../decoratePromise' +import { RealTimeRoomStreamListeners } from '../../types' + +export interface RoomSessionStreamEnded { + id: string + roomId: string + roomSessionId: string + state: RoomSessionStream['state'] + duration?: number + url?: string + startedAt?: Date + endedAt?: Date +} + +export interface RoomSessionStreamPromise + extends Promise, + Promisify { + onStarted: () => Promise + onEnded: () => Promise + listen: ( + listeners: RealTimeRoomStreamListeners + ) => Promise<() => Promise> + stop: () => Promise +} + +export const getters = [ + 'id', + 'roomId', + 'roomSessionId', + 'url', + 'state', + 'duration', + 'startedAt', + 'endedAt', +] + +export const methods = ['stop'] + +export function decorateStreamPromise( + this: RoomSession, + innerPromise: Promise +) { + // prettier-ignore + return (decoratePromise).call(this, { + promise: innerPromise, + namespace: 'stream', + methods, + getters, + }) as RoomSessionStreamPromise +} diff --git a/packages/realtime-api/src/video/RoomSessionStream/index.ts b/packages/realtime-api/src/video/RoomSessionStream/index.ts new file mode 100644 index 000000000..9ec319bd7 --- /dev/null +++ b/packages/realtime-api/src/video/RoomSessionStream/index.ts @@ -0,0 +1,3 @@ +export * from './RoomSessionStream' +export * from './decorateStreamPromise' +export { decorateStreamPromise } from './decorateStreamPromise' diff --git a/packages/realtime-api/src/video/Video.test.ts b/packages/realtime-api/src/video/Video.test.ts index dbf752b36..c4a438976 100644 --- a/packages/realtime-api/src/video/Video.test.ts +++ b/packages/realtime-api/src/video/Video.test.ts @@ -1,45 +1,67 @@ -import { actions } from '@signalwire/core' +import { EventEmitter, actions } from '@signalwire/core' +import { Video } from './Video' +import { RoomSession } from './RoomSession' +import { createClient } from '../client/createClient' import { configureFullStack } from '../testUtils' -import { RoomSessionConsumer } from './RoomSession' -import { createVideoObject, Video } from './Video' describe('Video Object', () => { let video: Video - const { store, session, emitter, destroy } = configureFullStack() - beforeEach(() => { - // remove all listeners before each run - emitter.removeAllListeners() + const { store, destroy } = configureFullStack() - video = createVideoObject({ - store, - // @ts-expect-error - emitter, - }) + const userOptions = { + host: 'example.com', + project: 'example.project', + token: 'example.token', + store, + } + + beforeEach(() => { + const swClientMock = { + userOptions, + client: createClient(userOptions), + } + // @ts-expect-error + video = new Video(swClientMock) + // @ts-expect-error + video._client.execute = jest.fn() // @ts-expect-error - video.execute = jest.fn() + video._client.runWorker = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() }) afterAll(() => { destroy() }) - it('should not invoke execute without event listeners', async () => { - await video.subscribe() - // @ts-expect-error - expect(video.execute).not.toHaveBeenCalled() + it('should have an event emitter', () => { + expect(video['emitter']).toBeInstanceOf(EventEmitter) }) - it('should invoke execute with event listeners', async () => { - video.on('room.started', jest.fn) - await video.subscribe() + it('should declare the correct event map', () => { + const expectedEventMap = { + onRoomStarted: 'room.started', + onRoomEnded: 'room.ended', + } + expect(video['_eventMap']).toEqual(expectedEventMap) + }) + + it('should subscribe to events', async () => { + await video.listen({ + onRoomStarted: jest.fn(), + onRoomEnded: jest.fn(), + }) + // @ts-expect-error - expect(video.execute).toHaveBeenCalledWith({ + expect(video._client.execute).toHaveBeenCalledWith({ method: 'signalwire.subscribe', params: { get_initial_state: true, event_channel: 'video.rooms', - events: ['video.room.started'], + events: ['video.room.started', 'video.room.ended'], }, }) }) @@ -54,142 +76,90 @@ describe('Video Object', () => { `{"jsonrpc":"2.0","id":"uuid1","method":"signalwire.event","params":{"params":{"room":{"recording":false,"room_session_id":"session-two","name":"Second Room","hide_video_muted":false,"music_on_hold":false,"room_id":"room_id","event_channel":"${eventChannelTwo}"},"room_session_id":"session-two","room_id":"room_id","room_session":{"recording":false,"name":"Second Room","hide_video_muted":false,"id":"session-two","music_on_hold":false,"room_id":"room_id","event_channel":"${eventChannelTwo}"}},"timestamp":1631692502.1308,"event_type":"video.room.started","event_channel":"video.rooms.4b7ae78a-d02e-4889-a63b-08b156d5916e"}}` ) - it('should pass a Room obj to the handler', (done) => { - video.on('room.started', (room) => { - expect(room.id).toBe('session-one') - expect(room.name).toBe('First Room') - expect(room.videoMute).toBeDefined() - expect(room.videoUnmute).toBeDefined() - expect(room.getMembers).toBeDefined() - expect(room.subscribe).toBeDefined() - done() - }) - - video.subscribe().then(() => { - session.dispatch(actions.socketMessageAction(firstRoom)) + it('should pass a room object to the listener', async () => { + const promise = new Promise(async (resolve) => { + await video.listen({ + onRoomStarted: (room) => { + expect(room.id).toBe('session-one') + expect(room.name).toBe('First Room') + expect(room.videoMute).toBeDefined() + expect(room.videoUnmute).toBeDefined() + expect(room.getMembers).toBeDefined() + resolve() + }, + }) }) - }) - - it('should *not* destroy the cached obj when an event has no longer handlers attached', async () => { - const destroyer = jest.fn() - const h = (room: any) => { - room._destroyer = destroyer - } - video.on('room.started', h) - - await video.subscribe() - session.dispatch(actions.socketMessageAction(firstRoom)) - - video.off('room.started', h) - expect(destroyer).not.toHaveBeenCalled() - }) - - it('should *not* destroy the cached obj when there are existing listeners attached', async () => { - const destroyer = jest.fn() - const h = (room: any) => { - room._destroyer = destroyer - } - video.on('room.started', h) - video.on('room.started', () => {}) - - await video.subscribe() - session.dispatch(actions.socketMessageAction(firstRoom)) - - video.off('room.started', h) - expect(destroyer).not.toHaveBeenCalled() - }) - - it('should *not* destroy the cached obj when .off is called with no handler', async () => { - const destroyer = jest.fn() - const h = (room: any) => { - room._destroyer = destroyer - } - video.on('room.started', h) - video.on('room.started', () => {}) - video.on('room.started', () => {}) - await video.subscribe() - session.dispatch(actions.socketMessageAction(firstRoom)) + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(firstRoom) + ) - video.off('room.started') - expect(destroyer).not.toHaveBeenCalled() + await promise }) it('each room object should use its own payload from the Proxy', async () => { - const mockExecute = jest.fn() - const mockNameCheck = jest.fn() - const promise = new Promise((resolve) => { - video.on('room.started', (room) => { - expect(room.videoMute).toBeDefined() - expect(room.videoUnmute).toBeDefined() - expect(room.getMembers).toBeDefined() - expect(room.subscribe).toBeDefined() - - room.on('member.joined', jest.fn) - // @ts-expect-error - room.execute = mockExecute - room.subscribe() - mockNameCheck(room.name) - - if (room.id === 'session-two') { - resolve(undefined) - } + const promise = new Promise(async (resolve) => { + await video.listen({ + onRoomStarted: (room) => { + expect(room.videoMute).toBeDefined() + expect(room.videoUnmute).toBeDefined() + expect(room.getMembers).toBeDefined() + expect(room.listen).toBeDefined() + if (room.id === 'session-two') { + resolve() + } + }, + onRoomEnded: () => {}, }) }) - await video.subscribe() - - session.dispatch(actions.socketMessageAction(firstRoom)) - session.dispatch(actions.socketMessageAction(secondRoom)) - - await promise + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(firstRoom) + ) + // @ts-expect-error + video._client.store.channels.sessionChannel.put( + actions.socketMessageAction(secondRoom) + ) - expect(mockExecute).toHaveBeenCalledTimes(2) - expect(mockExecute).toHaveBeenNthCalledWith(1, { - method: 'signalwire.subscribe', - params: { - event_channel: eventChannelOne, - events: ['video.member.joined', 'video.room.subscribed'], - get_initial_state: true, - }, - }) - expect(mockExecute).toHaveBeenNthCalledWith(2, { + // @ts-expect-error + expect(video._client.execute).toHaveBeenCalledTimes(1) + // @ts-expect-error + expect(video._client.execute).toHaveBeenNthCalledWith(1, { method: 'signalwire.subscribe', params: { - event_channel: eventChannelTwo, - events: ['video.member.joined', 'video.room.subscribed'], + event_channel: 'video.rooms', + events: ['video.room.started', 'video.room.ended'], get_initial_state: true, }, }) - // Check room.name exposed - expect(mockNameCheck).toHaveBeenCalledTimes(2) - expect(mockNameCheck).toHaveBeenNthCalledWith(1, 'First Room') - expect(mockNameCheck).toHaveBeenNthCalledWith(2, 'Second Room') + await promise }) }) - describe('video.room.ended event', () => { - const roomEndedEvent = JSON.parse( - `{"jsonrpc":"2.0","id":"uuid2","method":"signalwire.event","params":{"params":{"room":{"recording":false,"room_session_id":"session-one","name":"First Room","hide_video_muted":false,"music_on_hold":false,"room_id":"room_id","event_channel":"room."},"room_session_id":"session-one","room_id":"room_id","room_session":{"recording":false,"name":"First Room","hide_video_muted":false,"id":"session-one","music_on_hold":false,"room_id":"room_id","event_channel":"room."}},"timestamp":1631692510.415,"event_type":"video.room.ended","event_channel":"video.rooms.4b7ae78a-d02e-4889-a63b-08b156d5916e"}}` - ) - - it('should pass a Room obj to the handler', (done) => { - video.on('room.ended', (room) => { - expect(room.id).toBe('session-one') - expect(room.name).toBe('First Room') - expect(room.videoMute).toBeDefined() - expect(room.videoUnmute).toBeDefined() - expect(room.getMembers).toBeDefined() - expect(room.subscribe).toBeDefined() - done() - }) - - video.subscribe().then(() => { - session.dispatch(actions.socketMessageAction(roomEndedEvent)) - }) - }) - }) + // describe('video.room.ended event', () => { + // const roomEndedEvent = JSON.parse( + // `{"jsonrpc":"2.0","id":"uuid2","method":"signalwire.event","params":{"params":{"room":{"recording":false,"room_session_id":"session-one","name":"First Room","hide_video_muted":false,"music_on_hold":false,"room_id":"room_id","event_channel":"room."},"room_session_id":"session-one","room_id":"room_id","room_session":{"recording":false,"name":"First Room","hide_video_muted":false,"id":"session-one","music_on_hold":false,"room_id":"room_id","event_channel":"room."}},"timestamp":1631692510.415,"event_type":"video.room.ended","event_channel":"video.rooms.4b7ae78a-d02e-4889-a63b-08b156d5916e"}}` + // ) + + // it('should pass a Room obj to the handler', (done) => { + // video.listen({ + // onRoomEnded: (room) => { + // expect(room.id).toBe('session-one') + // expect(room.name).toBe('First Room') + // expect(room.videoMute).toBeDefined() + // expect(room.videoUnmute).toBeDefined() + // expect(room.getMembers).toBeDefined() + // done() + // }, + // }) + + // // @ts-expect-error + // video._client.store.dispatch(actions.socketMessageAction(roomEndedEvent)) + // }) + // }) describe('getRoomSessions()', () => { it('should be defined', () => { @@ -199,7 +169,7 @@ describe('Video Object', () => { it('should return an obj with a list of RoomSession objects', async () => { // @ts-expect-error - ;(video.execute as jest.Mock).mockResolvedValueOnce({ + ;(video._client.execute as jest.Mock).mockResolvedValueOnce({ code: '200', message: 'OK', rooms: [ @@ -269,7 +239,7 @@ describe('Video Object', () => { const result = await video.getRoomSessions() expect(result.roomSessions).toHaveLength(2) - expect(result.roomSessions[0]).toBeInstanceOf(RoomSessionConsumer) + expect(result.roomSessions[0]).toBeInstanceOf(RoomSession) expect(result.roomSessions[0].id).toBe( '25ab8daa-2639-45ed-bc73-69b664f55eff' ) @@ -280,7 +250,7 @@ describe('Video Object', () => { expect(result.roomSessions[0].recording).toBe(true) expect(result.roomSessions[0].getMembers).toBeDefined() - expect(result.roomSessions[1]).toBeInstanceOf(RoomSessionConsumer) + expect(result.roomSessions[1]).toBeInstanceOf(RoomSession) expect(result.roomSessions[1].id).toBe( 'c22fa141-a3f0-4923-b44c-e49aa318c3dd' ) @@ -301,7 +271,7 @@ describe('Video Object', () => { it('should return a RoomSession object', async () => { // @ts-expect-error - ;(video.execute as jest.Mock).mockResolvedValueOnce({ + ;(video._client.execute as jest.Mock).mockResolvedValueOnce({ room: { room_id: '776f0ece-75ce-4f84-8ce6-bd5677f2cbb9', id: '25ab8daa-2639-45ed-bc73-69b664f55eff', @@ -340,7 +310,7 @@ describe('Video Object', () => { '25ab8daa-2639-45ed-bc73-69b664f55eff' ) - expect(result.roomSession).toBeInstanceOf(RoomSessionConsumer) + expect(result.roomSession).toBeInstanceOf(RoomSession) expect(result.roomSession.id).toBe('25ab8daa-2639-45ed-bc73-69b664f55eff') expect(result.roomSession.roomId).toBe( '776f0ece-75ce-4f84-8ce6-bd5677f2cbb9' diff --git a/packages/realtime-api/src/video/Video.ts b/packages/realtime-api/src/video/Video.ts index a5de02c05..af10f9c2d 100644 --- a/packages/realtime-api/src/video/Video.ts +++ b/packages/realtime-api/src/video/Video.ts @@ -1,134 +1,71 @@ import { - BaseComponentOptions, - connect, - ConsumerContract, RoomSessionRecording, RoomSessionPlayback, + validateEventsToSubscribe, + EventEmitter, } from '@signalwire/core' -import { AutoSubscribeConsumer } from '../AutoSubscribeConsumer' -import type { RealtimeClient } from '../client/Client' import { - RealTimeRoomApiEvents, - RealTimeVideoApiEvents, - RealTimeVideoApiEventsHandlerMapping, - RealTimeRoomApiEventsHandlerMapping, + RealTimeRoomEvents, + RealTimeVideoEvents, + RealTimeVideoEventsHandlerMapping, + RealTimeRoomEventsHandlerMapping, + RealTimeVideoListenersEventsMapping, + RealTimeVideoListeners, } from '../types/video' -import { - RoomSession, - RoomSessionFullState, - RoomSessionUpdated, - createRoomSessionObject, -} from './RoomSession' +import { RoomSession, RoomSessionAPI } from './RoomSession' import type { RoomSessionMember, RoomSessionMemberUpdated, } from './RoomSessionMember' import { videoCallingWorker } from './workers' +import { SWClient } from '../SWClient' +import { BaseVideo } from './BaseVideo' + +export class Video extends BaseVideo< + RealTimeVideoListeners, + RealTimeVideoEvents +> { + protected _eventChannel = 'video.rooms' + protected _eventMap: RealTimeVideoListenersEventsMapping = { + onRoomStarted: 'room.started', + onRoomEnded: 'room.ended', + } -export interface Video extends ConsumerContract { - /** @internal */ - subscribe(): Promise - /** @internal */ - _session: RealtimeClient - /** - * Disconnects this client. The client will stop receiving events and you will - * need to create a new instance if you want to use it again. - * - * @example - * - * ```js - * client.disconnect() - * ``` - */ - disconnect(): void - - getRoomSessions(): Promise<{ roomSessions: RoomSession[] }> - getRoomSessionById(id: string): Promise<{ roomSession: RoomSession }> -} -export type { - RealTimeRoomApiEvents, - RealTimeRoomApiEventsHandlerMapping, - RealTimeVideoApiEvents, - RealTimeVideoApiEventsHandlerMapping, - RoomSession, - RoomSessionFullState, - RoomSessionMember, - RoomSessionMemberUpdated, - RoomSessionPlayback, - RoomSessionRecording, - RoomSessionUpdated, -} - -export type { - ClientEvents, - EmitterContract, - EntityUpdated, - GlobalVideoEvents, - InternalVideoMemberEntity, - LayoutChanged, - MEMBER_UPDATED_EVENTS, - MemberCommandParams, - MemberCommandWithValueParams, - MemberCommandWithVolumeParams, - MemberJoined, - MemberLeft, - MemberListUpdated, - MemberTalking, - MemberTalkingEnded, - MemberTalkingEventNames, - MemberTalkingStart, - MemberTalkingStarted, - MemberTalkingStop, - MemberUpdated, - MemberUpdatedEventNames, - PlaybackEnded, - PlaybackStarted, - PlaybackUpdated, - RecordingEnded, - RecordingStarted, - RecordingUpdated, - RoomEnded, - RoomStarted, - RoomSubscribed, - RoomUpdated, - SipCodec, - VideoLayoutEventNames, - VideoMemberContract, - VideoMemberEntity, - VideoMemberEventNames, - VideoMemberType, - VideoPlaybackEventNames, - VideoPosition, - VideoRecordingEventNames, -} from '@signalwire/core' - -class VideoAPI extends AutoSubscribeConsumer { - constructor(options: BaseComponentOptions) { + constructor(options: SWClient) { super(options) - this.runWorker('videoCallWorker', { worker: videoCallingWorker }) + this._client.runWorker('videoCallingWorker', { + worker: videoCallingWorker, + initialState: { + video: this, + }, + }) } - /** @internal */ - protected subscribeParams = { - get_initial_state: true, + protected override getSubscriptions() { + const eventNamesWithPrefix = this.eventNames().map( + (event) => `video.${String(event)}` + ) as EventEmitter.EventNames[] + return validateEventsToSubscribe(eventNamesWithPrefix) } async getRoomSessions() { return new Promise<{ roomSessions: RoomSession[] }>( async (resolve, reject) => { try { - const { rooms = [] }: any = await this.execute({ + const { rooms = [] }: any = await this._client.execute({ method: 'video.rooms.get', params: {}, }) const roomInstances: RoomSession[] = [] rooms.forEach((room: any) => { - let roomInstance = this.instanceMap.get(room.id) + let roomInstance = this._client.instanceMap.get( + room.id + ) if (!roomInstance) { - roomInstance = createRoomSessionObject({ - store: this.store, + roomInstance = new RoomSessionAPI({ + video: this, payload: { room_session: room }, }) } else { @@ -137,7 +74,10 @@ class VideoAPI extends AutoSubscribeConsumer { }) } roomInstances.push(roomInstance) - this.instanceMap.set(roomInstance.id, roomInstance) + this._client.instanceMap.set( + roomInstance.id, + roomInstance + ) }) resolve({ roomSessions: roomInstances }) @@ -153,17 +93,17 @@ class VideoAPI extends AutoSubscribeConsumer { return new Promise<{ roomSession: RoomSession }>( async (resolve, reject) => { try { - const { room }: any = await this.execute({ + const { room }: any = await this._client.execute({ method: 'video.room.get', params: { room_session_id: id, }, }) - let roomInstance = this.instanceMap.get(room.id) + let roomInstance = this._client.instanceMap.get(room.id) if (!roomInstance) { - roomInstance = createRoomSessionObject({ - store: this.store, + roomInstance = new RoomSessionAPI({ + video: this, payload: { room_session: room }, }) } else { @@ -171,7 +111,10 @@ class VideoAPI extends AutoSubscribeConsumer { room_session: room, }) } - this.instanceMap.set(roomInstance.id, roomInstance) + this._client.instanceMap.set( + roomInstance.id, + roomInstance + ) resolve({ roomSession: roomInstance }) } catch (error) { @@ -183,30 +126,57 @@ class VideoAPI extends AutoSubscribeConsumer { } } -/** @internal */ -export const createVideoObject = (params: BaseComponentOptions): Video => { - const video = connect({ - store: params.store, - Component: VideoAPI, - })(params) - - const proxy = new Proxy