vg-x07df 0.1.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.
- package/.azure-pipelines/publish-public.yml +37 -0
- package/.azure-pipelines/publish.yml +39 -0
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/AUTO_JOIN_GUIDE.md +411 -0
- package/README.md +215 -0
- package/Screenshot 2025-09-24 at 14.34.48.png +0 -0
- package/Screenshot 2025-10-04 at 12.58.54.png +0 -0
- package/biome.json +48 -0
- package/examples/demo/.env.example +19 -0
- package/examples/demo/CHANGELOG.md +22 -0
- package/examples/demo/README.md +72 -0
- package/examples/demo/eslint.config.js +23 -0
- package/examples/demo/index.html +13 -0
- package/examples/demo/package.json +34 -0
- package/examples/demo/pnpm-lock.yaml +2098 -0
- package/examples/demo/pnpm-workspace.yaml +1 -0
- package/examples/demo/public/vite.svg +1 -0
- package/examples/demo/src/App.css +52 -0
- package/examples/demo/src/App.tsx +176 -0
- package/examples/demo/src/assets/react.svg +1 -0
- package/examples/demo/src/components/auth/LoginForm.css +144 -0
- package/examples/demo/src/components/auth/LoginForm.tsx +80 -0
- package/examples/demo/src/components/calling/AutoJoinSettings.tsx +213 -0
- package/examples/demo/src/components/calling/AutoJoinStatus.tsx +72 -0
- package/examples/demo/src/components/calling/CallInitiator.css +258 -0
- package/examples/demo/src/components/calling/CallInitiator.tsx +142 -0
- package/examples/demo/src/components/calling/CallNotifications.css +119 -0
- package/examples/demo/src/components/calling/CallNotifications.tsx +108 -0
- package/examples/demo/src/components/calling/IncomingCallModal.css +192 -0
- package/examples/demo/src/components/calling/IncomingCallModal.tsx +78 -0
- package/examples/demo/src/components/calling/MinimizedCall.css +156 -0
- package/examples/demo/src/components/calling/MinimizedCall.tsx +78 -0
- package/examples/demo/src/components/conference/ConferenceHeader.css +265 -0
- package/examples/demo/src/components/conference/ConferenceHeader.tsx +78 -0
- package/examples/demo/src/components/conference/EnhancedControlBar.css +356 -0
- package/examples/demo/src/components/conference/EnhancedControlBar.tsx +262 -0
- package/examples/demo/src/components/conference/PaginationControls.css +67 -0
- package/examples/demo/src/components/conference/PaginationControls.tsx +64 -0
- package/examples/demo/src/components/conference/ParticipantGrid.css +153 -0
- package/examples/demo/src/components/conference/ParticipantGrid.tsx +87 -0
- package/examples/demo/src/components/conference/ParticipantTile.css +210 -0
- package/examples/demo/src/components/conference/ParticipantTile.tsx +114 -0
- package/examples/demo/src/components/conference/VideoConference.css +214 -0
- package/examples/demo/src/components/conference/VideoConference.tsx +93 -0
- package/examples/demo/src/contexts/AuthContext.tsx +105 -0
- package/examples/demo/src/hooks/useAuth.ts +5 -0
- package/examples/demo/src/hooks/useCallTimer.ts +42 -0
- package/examples/demo/src/index.css +68 -0
- package/examples/demo/src/main.tsx +10 -0
- package/examples/demo/src/services/auth.service.ts +153 -0
- package/examples/demo/src/types/auth.types.ts +31 -0
- package/examples/demo/tsconfig.app.json +28 -0
- package/examples/demo/tsconfig.json +7 -0
- package/examples/demo/tsconfig.node.json +26 -0
- package/examples/demo/vite.config.ts +15 -0
- package/images/callpad-without-ai.png +0 -0
- package/package.json +28 -0
- package/packages/sdk/CHANGELOG.md +33 -0
- package/packages/sdk/LICENSE +21 -0
- package/packages/sdk/README.md +97 -0
- package/packages/sdk/documentation.md +1132 -0
- package/packages/sdk/openapi-ts.config.ts +7 -0
- package/packages/sdk/package.json +88 -0
- package/packages/sdk/src/core/auth.manager.ts +52 -0
- package/packages/sdk/src/core/events/event-bus.ts +301 -0
- package/packages/sdk/src/core/events/index.ts +8 -0
- package/packages/sdk/src/core/events/types.ts +165 -0
- package/packages/sdk/src/core/index.ts +3 -0
- package/packages/sdk/src/core/signal/api.config.ts +49 -0
- package/packages/sdk/src/core/signal/index.ts +16 -0
- package/packages/sdk/src/core/signal/signal.client.ts +101 -0
- package/packages/sdk/src/core/signal/types.ts +110 -0
- package/packages/sdk/src/core/socketio/handlers/base.handler.ts +212 -0
- package/packages/sdk/src/core/socketio/handlers/call-accepted.handler.ts +34 -0
- package/packages/sdk/src/core/socketio/handlers/call-canceled.handler.ts +34 -0
- package/packages/sdk/src/core/socketio/handlers/call-declined.handler.ts +29 -0
- package/packages/sdk/src/core/socketio/handlers/call-ended.handler.ts +40 -0
- package/packages/sdk/src/core/socketio/handlers/call-incoming.handler.ts +72 -0
- package/packages/sdk/src/core/socketio/handlers/call-join-info.handler.ts +181 -0
- package/packages/sdk/src/core/socketio/handlers/call-participant-joined.handler.ts +42 -0
- package/packages/sdk/src/core/socketio/handlers/call-participant-joining.handler.ts +42 -0
- package/packages/sdk/src/core/socketio/handlers/call-timeout.handler.ts +31 -0
- package/packages/sdk/src/core/socketio/handlers/handler.registry.ts +62 -0
- package/packages/sdk/src/core/socketio/handlers/index.ts +21 -0
- package/packages/sdk/src/core/socketio/handlers/participant-left.handler.ts +37 -0
- package/packages/sdk/src/core/socketio/handlers/schema.ts +130 -0
- package/packages/sdk/src/core/socketio/index.ts +5 -0
- package/packages/sdk/src/core/socketio/socket.manager.ts +187 -0
- package/packages/sdk/src/core/socketio/types.ts +14 -0
- package/packages/sdk/src/core/types.ts +23 -0
- package/packages/sdk/src/generated/api/core/ApiError.ts +21 -0
- package/packages/sdk/src/generated/api/core/ApiRequestOptions.ts +13 -0
- package/packages/sdk/src/generated/api/core/ApiResult.ts +7 -0
- package/packages/sdk/src/generated/api/core/CancelablePromise.ts +126 -0
- package/packages/sdk/src/generated/api/core/OpenAPI.ts +55 -0
- package/packages/sdk/src/generated/api/core/request.ts +339 -0
- package/packages/sdk/src/generated/api/index.ts +5 -0
- package/packages/sdk/src/generated/api/models.ts +219 -0
- package/packages/sdk/src/generated/api/services.ts +225 -0
- package/packages/sdk/src/hooks/index.ts +21 -0
- package/packages/sdk/src/hooks/useAutoJoin.ts +66 -0
- package/packages/sdk/src/hooks/useCallActions.ts +28 -0
- package/packages/sdk/src/hooks/useCallQuality.ts +416 -0
- package/packages/sdk/src/hooks/useCallState.ts +23 -0
- package/packages/sdk/src/hooks/useConnection.ts +15 -0
- package/packages/sdk/src/hooks/useDevices.ts +296 -0
- package/packages/sdk/src/hooks/useErrorRecovery.ts +299 -0
- package/packages/sdk/src/hooks/useErrors.ts +84 -0
- package/packages/sdk/src/hooks/useEvent.ts +188 -0
- package/packages/sdk/src/hooks/useMediaControls.ts +215 -0
- package/packages/sdk/src/hooks/useParticipantStatus.ts +318 -0
- package/packages/sdk/src/hooks/useParticipants.ts +111 -0
- package/packages/sdk/src/index.ts +66 -0
- package/packages/sdk/src/livekit/constants.ts +76 -0
- package/packages/sdk/src/livekit/device.manager.ts +172 -0
- package/packages/sdk/src/livekit/error-classifier.ts +155 -0
- package/packages/sdk/src/livekit/events/eventBridge.ts +371 -0
- package/packages/sdk/src/livekit/events/trackRegistry.ts +114 -0
- package/packages/sdk/src/livekit/index.ts +49 -0
- package/packages/sdk/src/livekit/livekit.service.ts +110 -0
- package/packages/sdk/src/livekit/media.controls.ts +315 -0
- package/packages/sdk/src/livekit/room.manager.ts +79 -0
- package/packages/sdk/src/livekit/track.utils.ts +230 -0
- package/packages/sdk/src/livekit/types.ts +135 -0
- package/packages/sdk/src/provider/RtcProvider.tsx +78 -0
- package/packages/sdk/src/services/call-actions.ts +260 -0
- package/packages/sdk/src/services/error-recovery.ts +461 -0
- package/packages/sdk/src/services/index.ts +2 -0
- package/packages/sdk/src/services/sdk-builder.ts +104 -0
- package/packages/sdk/src/state/errors.ts +163 -0
- package/packages/sdk/src/state/selectors.ts +28 -0
- package/packages/sdk/src/state/store.ts +36 -0
- package/packages/sdk/src/state/types.ts +151 -0
- package/packages/sdk/src/utils/logger.ts +183 -0
- package/packages/sdk/tsconfig.json +49 -0
- package/packages/sdk/tsup.config.ts +51 -0
- package/pnpm-workspace.yaml +4 -0
- package/tsconfig.base.json +19 -0
- package/turbo.json +34 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LocalParticipant,
|
|
3
|
+
LocalTrackPublication,
|
|
4
|
+
Room,
|
|
5
|
+
} from "livekit-client";
|
|
6
|
+
import { ConnectionState, Track } from "livekit-client";
|
|
7
|
+
import { SdkEventType, eventBus } from "../core/events";
|
|
8
|
+
import { rtcStore } from "../state/store";
|
|
9
|
+
import { SCREEN_SHARE_CONFIG } from "./constants";
|
|
10
|
+
import { classifyMediaError } from "./error-classifier";
|
|
11
|
+
import type { MediaActions } from "./types";
|
|
12
|
+
|
|
13
|
+
export class MediaControls implements MediaActions {
|
|
14
|
+
constructor(
|
|
15
|
+
private localParticipant: LocalParticipant,
|
|
16
|
+
private room: Room
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
private async executeWithOptimisticUpdate<T>(
|
|
20
|
+
stateUpdate: (enabled: boolean) => void,
|
|
21
|
+
operation: () => Promise<T>,
|
|
22
|
+
device: string,
|
|
23
|
+
getLastError: () => Error | undefined,
|
|
24
|
+
newValue: boolean
|
|
25
|
+
): Promise<T> {
|
|
26
|
+
const originalValue = !newValue;
|
|
27
|
+
|
|
28
|
+
// Optimistic update
|
|
29
|
+
stateUpdate(newValue);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return await operation();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
// Revert on error
|
|
35
|
+
stateUpdate(originalValue);
|
|
36
|
+
this.handleMediaError(device, error, getLastError());
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async enableCamera(): Promise<void> {
|
|
42
|
+
if (this.room.state !== ConnectionState.Connected) {
|
|
43
|
+
throw new Error("Cannot enable camera - room not connected");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await this.executeWithOptimisticUpdate(
|
|
47
|
+
(enabled) =>
|
|
48
|
+
rtcStore.getState().patch((state) => {
|
|
49
|
+
state.local.videoEnabled = enabled;
|
|
50
|
+
}),
|
|
51
|
+
() => this.localParticipant.setCameraEnabled(true),
|
|
52
|
+
"camera",
|
|
53
|
+
() => this.localParticipant.lastCameraError,
|
|
54
|
+
true
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Emit media enabled event
|
|
58
|
+
eventBus.emit(
|
|
59
|
+
SdkEventType.MEDIA_ENABLED,
|
|
60
|
+
{
|
|
61
|
+
participantId: this.localParticipant.identity,
|
|
62
|
+
mediaType: "video" as const,
|
|
63
|
+
timestamp: Date.now(),
|
|
64
|
+
},
|
|
65
|
+
"livekit"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async disableCamera(): Promise<void> {
|
|
70
|
+
if (this.room.state !== ConnectionState.Connected) {
|
|
71
|
+
throw new Error("Cannot disable camera - room not connected");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await this.executeWithOptimisticUpdate(
|
|
75
|
+
(enabled) =>
|
|
76
|
+
rtcStore.getState().patch((state) => {
|
|
77
|
+
state.local.videoEnabled = enabled;
|
|
78
|
+
}),
|
|
79
|
+
() => this.localParticipant.setCameraEnabled(false),
|
|
80
|
+
"camera",
|
|
81
|
+
() => this.localParticipant.lastCameraError,
|
|
82
|
+
false
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Emit media disabled event
|
|
86
|
+
eventBus.emit(
|
|
87
|
+
SdkEventType.MEDIA_DISABLED,
|
|
88
|
+
{
|
|
89
|
+
participantId: this.localParticipant.identity,
|
|
90
|
+
mediaType: "video" as const,
|
|
91
|
+
timestamp: Date.now(),
|
|
92
|
+
},
|
|
93
|
+
"livekit"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async enableMicrophone(): Promise<void> {
|
|
98
|
+
if (this.room.state !== ConnectionState.Connected) {
|
|
99
|
+
throw new Error("Cannot enable microphone - room not connected");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await this.executeWithOptimisticUpdate(
|
|
103
|
+
(enabled) =>
|
|
104
|
+
rtcStore.getState().patch((state) => {
|
|
105
|
+
state.local.audioEnabled = enabled;
|
|
106
|
+
}),
|
|
107
|
+
() => this.localParticipant.setMicrophoneEnabled(true),
|
|
108
|
+
"microphone",
|
|
109
|
+
() => this.localParticipant.lastMicrophoneError,
|
|
110
|
+
true
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Emit media enabled event
|
|
114
|
+
eventBus.emit(
|
|
115
|
+
SdkEventType.MEDIA_ENABLED,
|
|
116
|
+
{
|
|
117
|
+
participantId: this.localParticipant.identity,
|
|
118
|
+
mediaType: "audio" as const,
|
|
119
|
+
timestamp: Date.now(),
|
|
120
|
+
},
|
|
121
|
+
"livekit"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async disableMicrophone(): Promise<void> {
|
|
126
|
+
if (this.room.state !== ConnectionState.Connected) {
|
|
127
|
+
throw new Error("Cannot disable microphone - room not connected");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await this.executeWithOptimisticUpdate(
|
|
131
|
+
(enabled) =>
|
|
132
|
+
rtcStore.getState().patch((state) => {
|
|
133
|
+
state.local.audioEnabled = enabled;
|
|
134
|
+
}),
|
|
135
|
+
() => this.localParticipant.setMicrophoneEnabled(false),
|
|
136
|
+
"microphone",
|
|
137
|
+
() => this.localParticipant.lastMicrophoneError,
|
|
138
|
+
false
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Emit media disabled event
|
|
142
|
+
eventBus.emit(
|
|
143
|
+
SdkEventType.MEDIA_DISABLED,
|
|
144
|
+
{
|
|
145
|
+
participantId: this.localParticipant.identity,
|
|
146
|
+
mediaType: "audio" as const,
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
},
|
|
149
|
+
"livekit"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async toggleCamera(): Promise<void> {
|
|
154
|
+
const currentState = rtcStore.getState().local.videoEnabled;
|
|
155
|
+
if (currentState) {
|
|
156
|
+
await this.disableCamera();
|
|
157
|
+
} else {
|
|
158
|
+
await this.enableCamera();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async toggleMicrophone(): Promise<void> {
|
|
163
|
+
const currentState = rtcStore.getState().local.audioEnabled;
|
|
164
|
+
if (currentState) {
|
|
165
|
+
await this.disableMicrophone();
|
|
166
|
+
} else {
|
|
167
|
+
await this.enableMicrophone();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async enableScreenShare(): Promise<void> {
|
|
172
|
+
if (this.room.state !== ConnectionState.Connected) {
|
|
173
|
+
throw new Error("Cannot enable screen share - room not connected");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const currentScreenShare = this.localParticipant.getTrackPublication(
|
|
177
|
+
Track.Source.ScreenShare
|
|
178
|
+
);
|
|
179
|
+
if (currentScreenShare && !currentScreenShare.isMuted) {
|
|
180
|
+
throw new Error("Screen share is already enabled");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
// Optimistic update
|
|
185
|
+
rtcStore.getState().patch((state) => {
|
|
186
|
+
state.local.screenEnabled = true;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await this.localParticipant.setScreenShareEnabled(
|
|
190
|
+
true,
|
|
191
|
+
SCREEN_SHARE_CONFIG
|
|
192
|
+
);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// Revert on error
|
|
195
|
+
rtcStore.getState().patch((state) => {
|
|
196
|
+
state.local.screenEnabled = false;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
this.handleMediaError("screen_share", error);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async disableScreenShare(): Promise<void> {
|
|
205
|
+
if (this.room.state !== ConnectionState.Connected) {
|
|
206
|
+
throw new Error("Cannot disable screen share - room not connected");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// Optimistic update
|
|
211
|
+
rtcStore.getState().patch((state) => {
|
|
212
|
+
state.local.screenEnabled = false;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await this.localParticipant.setScreenShareEnabled(false);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// Revert on error
|
|
218
|
+
rtcStore.getState().patch((state) => {
|
|
219
|
+
state.local.screenEnabled = true;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
this.handleMediaError("screen_share", error);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async toggleScreenShare(): Promise<void> {
|
|
228
|
+
const currentState = rtcStore.getState().local.screenEnabled;
|
|
229
|
+
if (currentState) {
|
|
230
|
+
await this.disableScreenShare();
|
|
231
|
+
} else {
|
|
232
|
+
await this.enableScreenShare();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get the current screen share track publication
|
|
238
|
+
*/
|
|
239
|
+
getScreenSharePublication(): LocalTrackPublication | undefined {
|
|
240
|
+
return this.localParticipant.getTrackPublication(Track.Source.ScreenShare);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Check if screen share is currently active
|
|
245
|
+
*/
|
|
246
|
+
isScreenShareActive(): boolean {
|
|
247
|
+
const publication = this.getScreenSharePublication();
|
|
248
|
+
return publication
|
|
249
|
+
? !publication.isMuted && publication.track !== undefined
|
|
250
|
+
: false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private handleMediaError(
|
|
254
|
+
device: string,
|
|
255
|
+
error: unknown,
|
|
256
|
+
livekitError?: Error
|
|
257
|
+
): void {
|
|
258
|
+
// For screen share, we might want different error handling
|
|
259
|
+
if (device === "screen_share") {
|
|
260
|
+
// Screen share has unique error patterns
|
|
261
|
+
const errorMessage =
|
|
262
|
+
error instanceof Error ? error.message.toLowerCase() : "";
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
errorMessage.includes("permission") ||
|
|
266
|
+
errorMessage.includes("denied")
|
|
267
|
+
) {
|
|
268
|
+
rtcStore.getState().addError({
|
|
269
|
+
code: "SCREEN_SHARE_PERMISSION_DENIED",
|
|
270
|
+
message: "Screen share permission denied",
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
context: {
|
|
273
|
+
originalError: error,
|
|
274
|
+
device,
|
|
275
|
+
category: "permission" as const,
|
|
276
|
+
recoverable: true,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (errorMessage.includes("not supported")) {
|
|
283
|
+
rtcStore.getState().addError({
|
|
284
|
+
code: "SCREEN_SHARE_NOT_SUPPORTED",
|
|
285
|
+
message: "Screen share not supported by browser",
|
|
286
|
+
timestamp: Date.now(),
|
|
287
|
+
context: {
|
|
288
|
+
originalError: error,
|
|
289
|
+
device,
|
|
290
|
+
category: "device" as const,
|
|
291
|
+
recoverable: false,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Fall back to standard error classification
|
|
299
|
+
const mediaError = classifyMediaError(error, device, livekitError);
|
|
300
|
+
|
|
301
|
+
rtcStore.getState().addError({
|
|
302
|
+
code: mediaError.code,
|
|
303
|
+
message: mediaError.message,
|
|
304
|
+
timestamp: Date.now(),
|
|
305
|
+
context: {
|
|
306
|
+
originalError: error,
|
|
307
|
+
livekitError,
|
|
308
|
+
device,
|
|
309
|
+
category: mediaError.category,
|
|
310
|
+
recoverable: mediaError.recoverable,
|
|
311
|
+
mediaError,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionState,
|
|
3
|
+
Room,
|
|
4
|
+
RoomEvent,
|
|
5
|
+
type RoomOptions,
|
|
6
|
+
} from "livekit-client";
|
|
7
|
+
import { createLogger } from "../utils/logger";
|
|
8
|
+
import { DEFAULT_ROOM_OPTIONS, type LiveKitConnectionConfig } from "./";
|
|
9
|
+
|
|
10
|
+
export class RoomManager {
|
|
11
|
+
private readonly _room: Room;
|
|
12
|
+
private _preparingConnection: Promise<void> | null = null;
|
|
13
|
+
private logger = createLogger("livekit:room");
|
|
14
|
+
|
|
15
|
+
constructor(options?: Partial<RoomOptions>) {
|
|
16
|
+
this._room = new Room({
|
|
17
|
+
...DEFAULT_ROOM_OPTIONS,
|
|
18
|
+
...options,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Prepares the room connection for faster subsequent connect()
|
|
24
|
+
* This is optional but recommended for better UX
|
|
25
|
+
*/
|
|
26
|
+
async prepareConnection(url: string, token?: string): Promise<void> {
|
|
27
|
+
if (this._preparingConnection) {
|
|
28
|
+
return this._preparingConnection;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this._preparingConnection = this._room.prepareConnection(url, token);
|
|
32
|
+
try {
|
|
33
|
+
await this._preparingConnection;
|
|
34
|
+
} finally {
|
|
35
|
+
this._preparingConnection = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async connect(config: LiveKitConnectionConfig): Promise<void> {
|
|
40
|
+
// If we haven't prepared the connection, prepare it now
|
|
41
|
+
if (
|
|
42
|
+
!this._preparingConnection &&
|
|
43
|
+
this._room.state === ConnectionState.Disconnected
|
|
44
|
+
) {
|
|
45
|
+
await this.prepareConnection(config.url, config.token);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await this._room.connect(config.url, config.token);
|
|
49
|
+
|
|
50
|
+
// Start audio playback for browser policy compliance
|
|
51
|
+
try {
|
|
52
|
+
await this._room.startAudio();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Non-critical error - user might need to interact first
|
|
55
|
+
this.logger.debug(
|
|
56
|
+
"Audio start failed - user interaction may be required",
|
|
57
|
+
{ error }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async disconnect(): Promise<void> {
|
|
63
|
+
// Cancel any pending preparation
|
|
64
|
+
this._preparingConnection = null;
|
|
65
|
+
|
|
66
|
+
await this._room.disconnect();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get room(): Room {
|
|
70
|
+
return this._room;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
destroy(): void {
|
|
74
|
+
// Cancel any pending preparation
|
|
75
|
+
this._preparingConnection = null;
|
|
76
|
+
|
|
77
|
+
// Room cleanup is handled by LiveKit's disconnect
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type LocalTrack,
|
|
3
|
+
type RemoteTrack,
|
|
4
|
+
Track,
|
|
5
|
+
type TrackPublication,
|
|
6
|
+
} from "livekit-client";
|
|
7
|
+
import { createLogger } from "../utils/logger";
|
|
8
|
+
import { TRACK_ATTACHMENT_CONFIG } from "./constants";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Utility functions for working with LiveKit tracks
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const logger = createLogger("livekit:tracks");
|
|
15
|
+
|
|
16
|
+
export interface TrackAttachmentOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Maximum number of retry attempts for track attachment
|
|
19
|
+
*/
|
|
20
|
+
maxRetries?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Delay between retry attempts in milliseconds
|
|
23
|
+
*/
|
|
24
|
+
retryDelay?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Whether to use exponential backoff for retries
|
|
27
|
+
*/
|
|
28
|
+
exponentialBackoff?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Custom audio/video element attributes
|
|
31
|
+
*/
|
|
32
|
+
elementAttributes?: Record<string, string | boolean>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Attaches a track to an HTML media element with retry logic
|
|
37
|
+
*/
|
|
38
|
+
export async function attachTrackToElement(
|
|
39
|
+
track: Track,
|
|
40
|
+
element: HTMLMediaElement,
|
|
41
|
+
options: TrackAttachmentOptions = {}
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const config = {
|
|
44
|
+
...TRACK_ATTACHMENT_CONFIG,
|
|
45
|
+
...options,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let attempt = 0;
|
|
49
|
+
let lastError: Error | undefined;
|
|
50
|
+
|
|
51
|
+
while (attempt <= config.maxRetries) {
|
|
52
|
+
try {
|
|
53
|
+
// Apply custom attributes if provided
|
|
54
|
+
if (config.elementAttributes) {
|
|
55
|
+
for (const [key, value] of Object.entries(config.elementAttributes)) {
|
|
56
|
+
if (typeof value === "boolean") {
|
|
57
|
+
if (value) {
|
|
58
|
+
element.setAttribute(key, "");
|
|
59
|
+
} else {
|
|
60
|
+
element.removeAttribute(key);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
element.setAttribute(key, value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await track.attach(element);
|
|
69
|
+
return; // Success!
|
|
70
|
+
} catch (error) {
|
|
71
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
72
|
+
|
|
73
|
+
if (attempt >= config.maxRetries) {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Calculate delay with optional exponential backoff
|
|
78
|
+
const delay = config.exponentialBackoff
|
|
79
|
+
? config.retryDelay * 2 ** attempt
|
|
80
|
+
: config.retryDelay;
|
|
81
|
+
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
83
|
+
attempt++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Failed to attach track after ${config.maxRetries + 1} attempts: ${lastError?.message}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Detaches a track from an HTML media element safely
|
|
94
|
+
*/
|
|
95
|
+
export function detachTrackFromElement(
|
|
96
|
+
track: Track,
|
|
97
|
+
element?: HTMLMediaElement
|
|
98
|
+
): void {
|
|
99
|
+
try {
|
|
100
|
+
if (element) {
|
|
101
|
+
track.detach(element);
|
|
102
|
+
} else {
|
|
103
|
+
track.detach();
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
logger.warn("Failed to detach track", { error });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Attaches multiple tracks to their respective elements
|
|
112
|
+
*/
|
|
113
|
+
export async function attachTracks(
|
|
114
|
+
trackElements: Array<{
|
|
115
|
+
track: Track;
|
|
116
|
+
element: HTMLMediaElement;
|
|
117
|
+
options?: TrackAttachmentOptions;
|
|
118
|
+
}>
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const attachmentPromises = trackElements.map(({ track, element, options }) =>
|
|
121
|
+
attachTrackToElement(track, element, options)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
await Promise.all(attachmentPromises);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Detaches multiple tracks from their elements
|
|
129
|
+
*/
|
|
130
|
+
export function detachTracks(
|
|
131
|
+
trackElements: Array<{ track: Track; element?: HTMLMediaElement }>
|
|
132
|
+
): void {
|
|
133
|
+
for (const { track, element } of trackElements) {
|
|
134
|
+
detachTrackFromElement(track, element);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Helper to get track from publication safely
|
|
140
|
+
*/
|
|
141
|
+
export function getTrackFromPublication(
|
|
142
|
+
publication: TrackPublication
|
|
143
|
+
): Track | undefined {
|
|
144
|
+
return publication.track || undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Helper to check if a track is ready for attachment
|
|
149
|
+
*/
|
|
150
|
+
export function isTrackReady(track: Track): boolean {
|
|
151
|
+
return track.mediaStream?.active ?? false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Creates an audio or video element for track attachment
|
|
156
|
+
*/
|
|
157
|
+
export function createMediaElement(
|
|
158
|
+
track: Track,
|
|
159
|
+
attributes: Record<string, string | boolean> = {}
|
|
160
|
+
): HTMLMediaElement {
|
|
161
|
+
const element =
|
|
162
|
+
track.kind === Track.Kind.Video
|
|
163
|
+
? document.createElement("video")
|
|
164
|
+
: document.createElement("audio");
|
|
165
|
+
|
|
166
|
+
// Apply default attributes
|
|
167
|
+
const defaultAttributes = {
|
|
168
|
+
autoplay: true,
|
|
169
|
+
playsInline: true,
|
|
170
|
+
controls: false,
|
|
171
|
+
muted: track.kind === Track.Kind.Video, // Auto-mute video to allow autoplay
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const allAttributes = { ...defaultAttributes, ...attributes };
|
|
175
|
+
|
|
176
|
+
for (const [key, value] of Object.entries(allAttributes)) {
|
|
177
|
+
if (typeof value === "boolean") {
|
|
178
|
+
if (value) {
|
|
179
|
+
element.setAttribute(key, "");
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
element.setAttribute(key, value);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return element;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Utility to handle track visibility (for video tracks)
|
|
191
|
+
*/
|
|
192
|
+
export function setTrackVisibility(
|
|
193
|
+
element: HTMLVideoElement,
|
|
194
|
+
visible: boolean
|
|
195
|
+
): void {
|
|
196
|
+
if (visible) {
|
|
197
|
+
element.style.display = "";
|
|
198
|
+
element.style.visibility = "";
|
|
199
|
+
} else {
|
|
200
|
+
element.style.display = "none";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Enhanced error information for track operations
|
|
206
|
+
*/
|
|
207
|
+
export interface TrackError extends Error {
|
|
208
|
+
code: string;
|
|
209
|
+
track?: Track | undefined;
|
|
210
|
+
element?: HTMLMediaElement | undefined;
|
|
211
|
+
retryable: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Creates a standardized track error
|
|
216
|
+
*/
|
|
217
|
+
export function createTrackError(
|
|
218
|
+
message: string,
|
|
219
|
+
code: string,
|
|
220
|
+
track?: Track | undefined,
|
|
221
|
+
element?: HTMLMediaElement | undefined,
|
|
222
|
+
retryable = true
|
|
223
|
+
): TrackError {
|
|
224
|
+
const error = new Error(message) as TrackError;
|
|
225
|
+
error.code = code;
|
|
226
|
+
error.track = track;
|
|
227
|
+
error.element = element;
|
|
228
|
+
error.retryable = retryable;
|
|
229
|
+
return error;
|
|
230
|
+
}
|