@ermis-network/ermis-chat-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,400 @@
1
+ import { base64Encode, createPacketWithHeader } from './utils';
2
+ import { AudioConfig, INodeCall, TransceiverState, VideoConfig } from './types';
3
+
4
+ export class MediaStreamSender {
5
+ private videoEncoder: VideoEncoder | null = null;
6
+ private audioEncoder: AudioEncoder | null = null;
7
+ private videoReader: ReadableStreamDefaultReader<VideoFrame> | null = null;
8
+
9
+ private localStream: MediaStream | null = null;
10
+
11
+ private videoConfig: VideoConfig | null = null;
12
+ private audioConfig: AudioConfig | null = null;
13
+
14
+ private videoConfigSent: boolean = false;
15
+ private audioConfigSent: boolean = false;
16
+
17
+ private hasVideo: boolean = false;
18
+ private hasAudio: boolean = false;
19
+
20
+ private forceKeyFrame: boolean = false;
21
+
22
+ private nodeCall: INodeCall;
23
+
24
+ constructor(nodeCall: INodeCall) {
25
+ this.nodeCall = nodeCall;
26
+ }
27
+
28
+ /**
29
+ * Bắt đầu xử lý MediaStream
30
+ */
31
+ public async connect(address: string): Promise<void> {
32
+ try {
33
+ await this.nodeCall.connect(address);
34
+ await this.sendConnected();
35
+ await this.sendConfigs();
36
+ } catch (error) {
37
+ console.error('Error starting MediaStreamSender:', error);
38
+ }
39
+ }
40
+
41
+ public async sendConfigs(): Promise<void> {
42
+ try {
43
+ await this.sendTransceiverState(this.hasAudio, this.hasVideo);
44
+ await this.sendAudioConfig();
45
+
46
+ const videoTrack = this.localStream?.getVideoTracks()[0];
47
+ if (videoTrack) {
48
+ await this.sendVideoConfig();
49
+ }
50
+ } catch (error) {
51
+ console.error('Error sending configs:', error);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Dừng và reset encoders
57
+ */
58
+ public stop = (): void => {
59
+ if (this.videoReader) {
60
+ try {
61
+ this.videoReader.cancel('Stream stopped').catch(() => {});
62
+ } catch (e) {}
63
+ this.videoReader = null;
64
+ }
65
+
66
+ if (this.videoEncoder) {
67
+ try {
68
+ if (this.videoEncoder.state !== 'closed') {
69
+ this.videoEncoder.reset(); // Xả frame
70
+ this.videoEncoder.close();
71
+ }
72
+ } catch (e) {}
73
+ this.videoEncoder = null;
74
+ }
75
+
76
+ // Reset and close audio encoder
77
+ if (this.audioEncoder) {
78
+ try {
79
+ if (this.audioEncoder.state !== 'closed') {
80
+ this.audioEncoder.reset();
81
+ this.audioEncoder.close();
82
+ }
83
+ } catch (e) {}
84
+ this.audioEncoder = null;
85
+ }
86
+
87
+ // Reset configs and flags
88
+ this.videoConfig = null;
89
+ this.audioConfig = null;
90
+
91
+ this.videoConfigSent = false;
92
+ this.audioConfigSent = false;
93
+ this.hasVideo = false;
94
+ this.hasAudio = false;
95
+
96
+ if (this.localStream) {
97
+ this.localStream.getTracks().forEach((track) => track.stop());
98
+ this.localStream = null;
99
+ }
100
+ };
101
+
102
+ public initAudioEncoder = (audioTrack: MediaStreamTrack): void => {
103
+ this.localStream = new MediaStream([audioTrack]);
104
+ this.audioConfigSent = false;
105
+ this.hasAudio = !!audioTrack;
106
+
107
+ const audioEncoder = new AudioEncoder({
108
+ output: (chunk, metadata) => {
109
+ if (metadata?.decoderConfig && !this.audioConfigSent) {
110
+ let description: string | undefined = undefined;
111
+ if (metadata.decoderConfig.description) {
112
+ description = base64Encode(metadata.decoderConfig.description as ArrayBuffer);
113
+ }
114
+
115
+ this.audioConfig = {
116
+ codec: metadata.decoderConfig.codec ?? 'opus',
117
+ sampleRate: metadata.decoderConfig.sampleRate ?? 48000,
118
+ numberOfChannels: metadata.decoderConfig.numberOfChannels ?? 1,
119
+ ...(description && { description }),
120
+ };
121
+ }
122
+
123
+ if (chunk && this.isReadyToSendData('audio')) {
124
+ const data = new ArrayBuffer(chunk.byteLength);
125
+ chunk.copyTo(data);
126
+ // const timestamp = Math.floor(chunk.timestamp / 1000);
127
+ const timestamp = chunk.timestamp;
128
+
129
+ const packet = createPacketWithHeader(data, timestamp, 'audio', null);
130
+ this.sendPacketOrQueue(packet, 'audio', null);
131
+ }
132
+ },
133
+ error: (e) => console.error('AudioEncoder error:', e),
134
+ });
135
+
136
+ audioEncoder.configure({
137
+ codec: 'mp4a.40.2',
138
+ sampleRate: 48000,
139
+ numberOfChannels: 1,
140
+ bitrate: 128000,
141
+ });
142
+
143
+ this.audioEncoder = audioEncoder;
144
+ this.processAudioFrames(audioTrack);
145
+ };
146
+
147
+ public initVideoEncoder(videoTrack: MediaStreamTrack): void {
148
+ if (this.localStream) {
149
+ this.localStream.addTrack(videoTrack);
150
+ }
151
+
152
+ this.videoConfigSent = false;
153
+ this.hasVideo = !!videoTrack;
154
+
155
+ const settings = videoTrack.getSettings();
156
+ const videoWidth = settings.width || 1280;
157
+ const videoHeight = settings.height || 720;
158
+
159
+ const videoEncoder = new VideoEncoder({
160
+ output: async (chunk, metadata) => {
161
+ if (metadata?.decoderConfig && !this.videoConfigSent) {
162
+ let description: string | undefined = undefined;
163
+ if (metadata.decoderConfig.description) {
164
+ description = base64Encode(metadata.decoderConfig.description as ArrayBuffer);
165
+ }
166
+
167
+ this.videoConfig = {
168
+ codec: metadata.decoderConfig.codec ?? 'hev1.1.6.L93.B0',
169
+ codedWidth: metadata.decoderConfig.codedWidth ?? videoWidth,
170
+ codedHeight: metadata.decoderConfig.codedHeight ?? videoHeight,
171
+ frameRate: 30.0,
172
+ orientation: 0,
173
+ ...(description && { description }),
174
+ };
175
+
176
+ await this.sendVideoConfig();
177
+ }
178
+
179
+ if (chunk && this.isReadyToSendData('video')) {
180
+ const data = new ArrayBuffer(chunk.byteLength);
181
+ chunk.copyTo(data);
182
+ const frameType = chunk.type === 'key' ? 'video-key' : 'video-delta';
183
+ // const timestamp = Math.floor(chunk.timestamp / 1000);
184
+ const timestamp = chunk.timestamp;
185
+
186
+ const packet = createPacketWithHeader(data, timestamp, frameType, null);
187
+ this.sendPacketOrQueue(packet, 'video', frameType);
188
+ }
189
+ },
190
+ error: (e) => console.error('VideoEncoder error:', e),
191
+ });
192
+
193
+ videoEncoder.configure({
194
+ codec: 'hev1.1.6.L93.B0',
195
+ width: videoWidth,
196
+ height: videoHeight,
197
+ bitrate: 500_000,
198
+ framerate: 30,
199
+ latencyMode: 'realtime',
200
+ hardwareAcceleration: 'prefer-hardware',
201
+ });
202
+
203
+ this.videoEncoder = videoEncoder;
204
+ this.processVideoFrames(videoTrack);
205
+ }
206
+
207
+ public initEncoders = (stream: MediaStream): void => {
208
+ const videoTrack = stream.getVideoTracks()[0];
209
+ const audioTrack = stream.getAudioTracks()[0];
210
+
211
+ if (audioTrack) {
212
+ this.initAudioEncoder(audioTrack);
213
+ }
214
+
215
+ if (videoTrack) {
216
+ this.initVideoEncoder(videoTrack);
217
+ }
218
+ };
219
+
220
+ public sendTransceiverState = async (audioEnable: boolean, videoEnable: boolean) => {
221
+ const state: TransceiverState = {
222
+ audio_enable: !!audioEnable,
223
+ video_enable: !!videoEnable,
224
+ };
225
+ const configPacket = createPacketWithHeader(null, null, 'transciverState', state);
226
+ await this.nodeCall.sendControlFrame(configPacket);
227
+ };
228
+
229
+ public async replaceVideoTrack(track: MediaStreamTrack): Promise<void> {
230
+ // 1. Dừng reader của track cũ (quan trọng)
231
+ if (this.videoReader) {
232
+ try {
233
+ // Việc gọi cancel sẽ làm Promise tại dòng await read() bên dưới throw lỗi hoặc trả về done
234
+ await this.videoReader.cancel('Replacing track');
235
+ } catch (e) {
236
+ // Bỏ qua lỗi khi cancel
237
+ }
238
+ this.videoReader = null;
239
+ }
240
+
241
+ if (track) {
242
+ this.processVideoFrames(track);
243
+ }
244
+ }
245
+
246
+ public async replaceAudioTrack(track: MediaStreamTrack): Promise<void> {
247
+ if (track) {
248
+ this.processAudioFrames(track);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Yêu cầu gửi keyframe ngay lập tức (được gọi khi nhận REQUEST_KEY_FRAME từ receiver)
254
+ */
255
+ public requestKeyFrame = (): void => {
256
+ console.log('📥 KeyFrame requested');
257
+ this.forceKeyFrame = true;
258
+ };
259
+
260
+ // ================= PRIVATE METHODS =================
261
+
262
+ private processVideoFrames = async (videoTrack: MediaStreamTrack) => {
263
+ try {
264
+ // @ts-ignore: MediaStreamTrackProcessor is explicitly defined in WebCodecs types
265
+ const videoProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
266
+ this.videoReader = videoProcessor.readable.getReader();
267
+
268
+ let frameCounter = 0;
269
+ while (true) {
270
+ if (!this.videoReader) break;
271
+
272
+ const { done, value: frame } = await this.videoReader.read();
273
+ if (done) break;
274
+
275
+ if (!this.videoEncoder) {
276
+ frame?.close();
277
+ break;
278
+ }
279
+
280
+ if (frame) {
281
+ frameCounter += 1;
282
+ const keyFrame = frameCounter % 60 === 0 || this.forceKeyFrame;
283
+ if (this.forceKeyFrame) {
284
+ console.log('📤 Sending forced KeyFrame');
285
+ this.forceKeyFrame = false;
286
+ }
287
+ try {
288
+ this.videoEncoder.encode(frame, { keyFrame });
289
+ } catch (err) {
290
+ console.error('Encode error:', err);
291
+ } finally {
292
+ frame.close();
293
+ }
294
+ }
295
+ }
296
+ } catch (error: any) {
297
+ console.error(`Error processing video frames: ${error.message}`);
298
+ } finally {
299
+ if (this.videoReader) {
300
+ try {
301
+ this.videoReader.releaseLock();
302
+ } catch (e) {}
303
+ // this.videoReader = null;
304
+ }
305
+ }
306
+ };
307
+
308
+ private processAudioFrames = async (audioTrack: MediaStreamTrack) => {
309
+ // @ts-ignore
310
+ const audioProcessor = new MediaStreamTrackProcessor({ track: audioTrack });
311
+ const audioReader = audioProcessor.readable.getReader();
312
+
313
+ try {
314
+ while (true) {
315
+ const { done, value: frame } = await audioReader.read();
316
+ if (done) break;
317
+
318
+ if (!this.audioEncoder) {
319
+ frame?.close();
320
+ break;
321
+ }
322
+
323
+ if (frame) {
324
+ try {
325
+ this.audioEncoder.encode(frame);
326
+ } catch (err) {
327
+ console.error('Audio Encoding error:', err);
328
+ } finally {
329
+ frame.close();
330
+ }
331
+ }
332
+ }
333
+ } catch (error: any) {
334
+ console.error(`Error processing audio frames: ${error.message}`);
335
+ }
336
+ };
337
+
338
+ private isReadyToSendData = (type: 'video' | 'audio'): boolean => {
339
+ const videoReady = !this.hasVideo || this.videoConfigSent;
340
+ const audioReady = !this.hasAudio || this.audioConfigSent;
341
+ const allConfigsSent = videoReady && audioReady;
342
+
343
+ if (type === 'video') {
344
+ return allConfigsSent && this.videoConfigSent;
345
+ } else if (type === 'audio') {
346
+ return allConfigsSent && this.audioConfigSent;
347
+ }
348
+
349
+ return false;
350
+ };
351
+
352
+ private sendVideoConfig = async () => {
353
+ if (this.videoConfig && !this.videoConfigSent) {
354
+ try {
355
+ const configPacket = createPacketWithHeader(null, null, 'videoConfig', this.videoConfig);
356
+ await this.nodeCall.sendControlFrame(configPacket);
357
+ this.videoConfigSent = true;
358
+ } catch (error) {
359
+ console.error('Error sending video config:', error);
360
+ }
361
+ }
362
+ };
363
+
364
+ private sendAudioConfig = async () => {
365
+ if (this.audioConfig && !this.audioConfigSent) {
366
+ try {
367
+ const configPacket = createPacketWithHeader(null, null, 'audioConfig', this.audioConfig);
368
+ await this.nodeCall.sendControlFrame(configPacket);
369
+ this.audioConfigSent = true;
370
+ } catch (error) {
371
+ console.error('Error sending audio config:', error);
372
+ }
373
+ }
374
+ };
375
+
376
+ public sendConnected = async () => {
377
+ const configPacket = createPacketWithHeader(null, null, 'connected', null);
378
+ await this.nodeCall.sendControlFrame(configPacket);
379
+ };
380
+
381
+ private sendPacketOrQueue = async (
382
+ packet: Uint8Array,
383
+ type: 'video' | 'audio',
384
+ frameType: 'video-key' | 'video-delta' | null,
385
+ ) => {
386
+ if (!this.isReadyToSendData(type)) {
387
+ return;
388
+ }
389
+
390
+ if (type === 'audio') {
391
+ await this.nodeCall.sendAudioFrame(packet);
392
+ } else if (type === 'video') {
393
+ if (frameType === 'video-key') {
394
+ await this.nodeCall.beginWithGop(packet);
395
+ } else if (frameType === 'video-delta') {
396
+ await this.nodeCall.sendFrame(packet);
397
+ }
398
+ }
399
+ };
400
+ }
@@ -0,0 +1 @@
1
+ export default null;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Convert duration in seconds to mm:ss format.
3
+ */
4
+ function formatDuration(durationSec: string): string {
5
+ const sec = parseInt(durationSec, 10);
6
+ if (isNaN(sec) || sec < 0) return durationSec;
7
+ const minutes = Math.floor(sec / 60);
8
+ const seconds = sec % 60;
9
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
10
+ }
11
+
12
+ /**
13
+ * Resolve a user ID to a display name using the provided map.
14
+ * Falls back to the raw userId if no entry is found.
15
+ */
16
+ function resolveUser(userId: string, userMap: Record<string, string>): string {
17
+ return userMap[userId] ?? userId;
18
+ }
19
+
20
+ /**
21
+ * Parse a raw signal message string into a human-readable English sentence.
22
+ *
23
+ * Signal messages represent call events. The raw format is:
24
+ * `"<formatId> <userID> [<param1> <param2> ...]"`
25
+ *
26
+ * @param value - Raw signal message string from the server
27
+ * @param userMap - Mapping of user IDs → display names
28
+ * @returns Parsed English text, or the original string if unknown
29
+ */
30
+ export function parseSignalMessage(
31
+ value: string,
32
+ userMap: Record<string, string>,
33
+ ): string {
34
+ if (!value || typeof value !== 'string') return value ?? '';
35
+
36
+ const trimmed = value.trim();
37
+ if (!trimmed) return '';
38
+
39
+ const parts = trimmed.split(' ');
40
+ const formatId = parts[0];
41
+ const userId = parts[1] ?? '';
42
+ const userName = userId ? resolveUser(userId, userMap) : 'User';
43
+
44
+ switch (formatId) {
45
+ // 1: Audio call started
46
+ case '1':
47
+ return `📞 ${userName} started an audio call.`;
48
+
49
+ // 2: Audio call missed
50
+ case '2':
51
+ return `📞 Missed audio call from ${userName}.`;
52
+
53
+ // 3: Audio call ended (caller_id ender_id duration)
54
+ case '3': {
55
+ const enderId = parts[2] ?? '';
56
+ const duration = parts[3] ?? '0';
57
+ const enderName = enderId ? resolveUser(enderId, userMap) : 'User';
58
+ return `📞 Audio call by ${userName}, ended by ${enderName}. Duration: ${formatDuration(duration)}.`;
59
+ }
60
+
61
+ // 4: Video call started
62
+ case '4':
63
+ return `📹 ${userName} started a video call.`;
64
+
65
+ // 5: Video call missed
66
+ case '5':
67
+ return `📹 Missed video call from ${userName}.`;
68
+
69
+ // 6: Video call ended (caller_id ender_id duration)
70
+ case '6': {
71
+ const enderId = parts[2] ?? '';
72
+ const duration = parts[3] ?? '0';
73
+ const enderName = enderId ? resolveUser(enderId, userMap) : 'User';
74
+ return `📹 Video call by ${userName}, ended by ${enderName}. Duration: ${formatDuration(duration)}.`;
75
+ }
76
+
77
+ // 7: Audio call rejected
78
+ case '7':
79
+ return `📞 Audio call from ${userName} was rejected.`;
80
+
81
+ // 8: Video call rejected
82
+ case '8':
83
+ return `📹 Video call from ${userName} was rejected.`;
84
+
85
+ // 9: Audio call busy
86
+ case '9':
87
+ return `📞 Audio call from ${userName} — recipient was busy.`;
88
+
89
+ // 10: Video call busy
90
+ case '10':
91
+ return `📹 Video call from ${userName} — recipient was busy.`;
92
+
93
+ default:
94
+ return trimmed;
95
+ }
96
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Duration lookup: milliseconds → human-readable text.
3
+ */
4
+ const DURATION_MAP: Record<string, string> = {
5
+ '10000': '10 seconds',
6
+ '30000': '30 seconds',
7
+ '60000': '1 minute',
8
+ '300000': '5 minutes',
9
+ '900000': '15 minutes',
10
+ '3600000': '60 minutes',
11
+ };
12
+
13
+ /**
14
+ * Resolve a user ID to a display name using the provided map.
15
+ * Falls back to the raw userId if no entry is found.
16
+ */
17
+ function resolveUser(userId: string, userMap: Record<string, string>): string {
18
+ return userMap[userId] ?? userId;
19
+ }
20
+
21
+ /**
22
+ * Parse a raw system message string into a human-readable English sentence.
23
+ *
24
+ * The raw format is: `"<formatId> <userID> [<param1> <param2> ...]"`
25
+ *
26
+ * @param value - Raw system message string from the server
27
+ * @param userMap - Mapping of user IDs → display names
28
+ * @returns Parsed English text, or the original string if the format is unknown
29
+ */
30
+ export function parseSystemMessage(
31
+ value: string,
32
+ userMap: Record<string, string>,
33
+ ): string {
34
+ if (!value || typeof value !== 'string') return value ?? '';
35
+
36
+ const trimmed = value.trim();
37
+ if (!trimmed) return '';
38
+
39
+ const parts = trimmed.split(' ');
40
+ const formatId = parts[0];
41
+ const userId = parts[1] ?? '';
42
+ const userName = userId ? resolveUser(userId, userMap) : 'User';
43
+
44
+ switch (formatId) {
45
+ // 1: userName changed the channel name to channelName (may contain spaces)
46
+ case '1': {
47
+ const channelName = parts.slice(2).join(' ');
48
+ return `${userName} changed the channel name to ${channelName}.`;
49
+ }
50
+
51
+ // 2–13, 16–17: single-user actions
52
+ case '2':
53
+ return `${userName} changed the channel avatar.`;
54
+ case '3':
55
+ return `${userName} changed the channel description.`;
56
+ case '4':
57
+ return `${userName} was removed from the channel.`;
58
+ case '5':
59
+ return `${userName} was banned.`;
60
+ case '6':
61
+ return `${userName} was unbanned.`;
62
+ case '7':
63
+ return `${userName} was promoted to moderator.`;
64
+ case '8':
65
+ return `${userName} was demoted from moderator.`;
66
+ case '9':
67
+ return `${userName}'s permissions were updated.`;
68
+ case '10':
69
+ return `${userName} joined the channel.`;
70
+ case '11':
71
+ return `${userName} declined the channel invitation.`;
72
+ case '12':
73
+ return `${userName} left the channel.`;
74
+ case '13':
75
+ return `${userName} cleared the chat history.`;
76
+
77
+ // 14: channel type change (true = public, false = private)
78
+ case '14': {
79
+ const rawType = parts[2] ?? '';
80
+ const channelType = rawType === 'true' ? 'public' : 'private';
81
+ return `${userName} changed the channel to ${channelType}.`;
82
+ }
83
+
84
+ // 15: cooldown toggle / duration
85
+ case '15': {
86
+ const duration = parts[2] ?? '0';
87
+ if (duration === '0') {
88
+ return `${userName} disabled cooldown.`;
89
+ }
90
+ const durationText = DURATION_MAP[duration] ?? `${duration}ms`;
91
+ return `${userName} enabled cooldown for ${durationText}.`;
92
+ }
93
+
94
+ case '16':
95
+ return `${userName} updated the banned words.`;
96
+ case '17':
97
+ return `${userName} was added to the channel.`;
98
+
99
+ // 18: admin transfer (two user IDs)
100
+ case '18': {
101
+ const oldUserId = parts[1] ?? '';
102
+ const newUserId = parts[2] ?? '';
103
+ const oldUserName = oldUserId ? resolveUser(oldUserId, userMap) : 'User';
104
+ const newUserName = newUserId ? resolveUser(newUserId, userMap) : 'User';
105
+ return `Admin ${oldUserName} left and assigned ${newUserName} as the new admin.`;
106
+ }
107
+
108
+ // 19–20: pin / unpin (userId + msgID)
109
+ case '19':
110
+ return `${userName} pinned a message.`;
111
+ case '20':
112
+ return `${userName} unpinned a message.`;
113
+
114
+ default:
115
+ return trimmed;
116
+ }
117
+ }
@@ -0,0 +1,48 @@
1
+ import { ExtendableGenerics, DefaultGenerics, UserResponse } from './types';
2
+
3
+ /**
4
+ * TokenManager
5
+ *
6
+ * Manages token storage and retrieval for the chat client.
7
+ */
8
+ export class TokenManager<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> {
9
+ loadTokenPromise: Promise<string> | null;
10
+ token?: string;
11
+ user?: UserResponse<ErmisChatGenerics>;
12
+
13
+ constructor() {
14
+ this.loadTokenPromise = null;
15
+ }
16
+
17
+ /**
18
+ * Set the static string token.
19
+ */
20
+ setTokenOrProvider = async (tokenOrProvider: string | null, user: UserResponse<ErmisChatGenerics>) => {
21
+ this.user = user;
22
+
23
+ if (typeof tokenOrProvider === 'string') {
24
+ this.token = tokenOrProvider;
25
+ }
26
+
27
+ this.loadTokenPromise = Promise.resolve(this.token as string);
28
+ };
29
+
30
+ /**
31
+ * Resets the token manager.
32
+ */
33
+ reset = () => {
34
+ this.token = undefined;
35
+ this.user = undefined;
36
+ this.loadTokenPromise = null;
37
+ };
38
+
39
+ /**
40
+ * Resolves when token is ready.
41
+ */
42
+ tokenReady = () => this.loadTokenPromise;
43
+
44
+ /** Returns the current token */
45
+ getToken = () => {
46
+ return this.token;
47
+ };
48
+ }