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.
Files changed (140) hide show
  1. package/.azure-pipelines/publish-public.yml +37 -0
  2. package/.azure-pipelines/publish.yml +39 -0
  3. package/.changeset/README.md +8 -0
  4. package/.changeset/config.json +11 -0
  5. package/AUTO_JOIN_GUIDE.md +411 -0
  6. package/README.md +215 -0
  7. package/Screenshot 2025-09-24 at 14.34.48.png +0 -0
  8. package/Screenshot 2025-10-04 at 12.58.54.png +0 -0
  9. package/biome.json +48 -0
  10. package/examples/demo/.env.example +19 -0
  11. package/examples/demo/CHANGELOG.md +22 -0
  12. package/examples/demo/README.md +72 -0
  13. package/examples/demo/eslint.config.js +23 -0
  14. package/examples/demo/index.html +13 -0
  15. package/examples/demo/package.json +34 -0
  16. package/examples/demo/pnpm-lock.yaml +2098 -0
  17. package/examples/demo/pnpm-workspace.yaml +1 -0
  18. package/examples/demo/public/vite.svg +1 -0
  19. package/examples/demo/src/App.css +52 -0
  20. package/examples/demo/src/App.tsx +176 -0
  21. package/examples/demo/src/assets/react.svg +1 -0
  22. package/examples/demo/src/components/auth/LoginForm.css +144 -0
  23. package/examples/demo/src/components/auth/LoginForm.tsx +80 -0
  24. package/examples/demo/src/components/calling/AutoJoinSettings.tsx +213 -0
  25. package/examples/demo/src/components/calling/AutoJoinStatus.tsx +72 -0
  26. package/examples/demo/src/components/calling/CallInitiator.css +258 -0
  27. package/examples/demo/src/components/calling/CallInitiator.tsx +142 -0
  28. package/examples/demo/src/components/calling/CallNotifications.css +119 -0
  29. package/examples/demo/src/components/calling/CallNotifications.tsx +108 -0
  30. package/examples/demo/src/components/calling/IncomingCallModal.css +192 -0
  31. package/examples/demo/src/components/calling/IncomingCallModal.tsx +78 -0
  32. package/examples/demo/src/components/calling/MinimizedCall.css +156 -0
  33. package/examples/demo/src/components/calling/MinimizedCall.tsx +78 -0
  34. package/examples/demo/src/components/conference/ConferenceHeader.css +265 -0
  35. package/examples/demo/src/components/conference/ConferenceHeader.tsx +78 -0
  36. package/examples/demo/src/components/conference/EnhancedControlBar.css +356 -0
  37. package/examples/demo/src/components/conference/EnhancedControlBar.tsx +262 -0
  38. package/examples/demo/src/components/conference/PaginationControls.css +67 -0
  39. package/examples/demo/src/components/conference/PaginationControls.tsx +64 -0
  40. package/examples/demo/src/components/conference/ParticipantGrid.css +153 -0
  41. package/examples/demo/src/components/conference/ParticipantGrid.tsx +87 -0
  42. package/examples/demo/src/components/conference/ParticipantTile.css +210 -0
  43. package/examples/demo/src/components/conference/ParticipantTile.tsx +114 -0
  44. package/examples/demo/src/components/conference/VideoConference.css +214 -0
  45. package/examples/demo/src/components/conference/VideoConference.tsx +93 -0
  46. package/examples/demo/src/contexts/AuthContext.tsx +105 -0
  47. package/examples/demo/src/hooks/useAuth.ts +5 -0
  48. package/examples/demo/src/hooks/useCallTimer.ts +42 -0
  49. package/examples/demo/src/index.css +68 -0
  50. package/examples/demo/src/main.tsx +10 -0
  51. package/examples/demo/src/services/auth.service.ts +153 -0
  52. package/examples/demo/src/types/auth.types.ts +31 -0
  53. package/examples/demo/tsconfig.app.json +28 -0
  54. package/examples/demo/tsconfig.json +7 -0
  55. package/examples/demo/tsconfig.node.json +26 -0
  56. package/examples/demo/vite.config.ts +15 -0
  57. package/images/callpad-without-ai.png +0 -0
  58. package/package.json +28 -0
  59. package/packages/sdk/CHANGELOG.md +33 -0
  60. package/packages/sdk/LICENSE +21 -0
  61. package/packages/sdk/README.md +97 -0
  62. package/packages/sdk/documentation.md +1132 -0
  63. package/packages/sdk/openapi-ts.config.ts +7 -0
  64. package/packages/sdk/package.json +88 -0
  65. package/packages/sdk/src/core/auth.manager.ts +52 -0
  66. package/packages/sdk/src/core/events/event-bus.ts +301 -0
  67. package/packages/sdk/src/core/events/index.ts +8 -0
  68. package/packages/sdk/src/core/events/types.ts +165 -0
  69. package/packages/sdk/src/core/index.ts +3 -0
  70. package/packages/sdk/src/core/signal/api.config.ts +49 -0
  71. package/packages/sdk/src/core/signal/index.ts +16 -0
  72. package/packages/sdk/src/core/signal/signal.client.ts +101 -0
  73. package/packages/sdk/src/core/signal/types.ts +110 -0
  74. package/packages/sdk/src/core/socketio/handlers/base.handler.ts +212 -0
  75. package/packages/sdk/src/core/socketio/handlers/call-accepted.handler.ts +34 -0
  76. package/packages/sdk/src/core/socketio/handlers/call-canceled.handler.ts +34 -0
  77. package/packages/sdk/src/core/socketio/handlers/call-declined.handler.ts +29 -0
  78. package/packages/sdk/src/core/socketio/handlers/call-ended.handler.ts +40 -0
  79. package/packages/sdk/src/core/socketio/handlers/call-incoming.handler.ts +72 -0
  80. package/packages/sdk/src/core/socketio/handlers/call-join-info.handler.ts +181 -0
  81. package/packages/sdk/src/core/socketio/handlers/call-participant-joined.handler.ts +42 -0
  82. package/packages/sdk/src/core/socketio/handlers/call-participant-joining.handler.ts +42 -0
  83. package/packages/sdk/src/core/socketio/handlers/call-timeout.handler.ts +31 -0
  84. package/packages/sdk/src/core/socketio/handlers/handler.registry.ts +62 -0
  85. package/packages/sdk/src/core/socketio/handlers/index.ts +21 -0
  86. package/packages/sdk/src/core/socketio/handlers/participant-left.handler.ts +37 -0
  87. package/packages/sdk/src/core/socketio/handlers/schema.ts +130 -0
  88. package/packages/sdk/src/core/socketio/index.ts +5 -0
  89. package/packages/sdk/src/core/socketio/socket.manager.ts +187 -0
  90. package/packages/sdk/src/core/socketio/types.ts +14 -0
  91. package/packages/sdk/src/core/types.ts +23 -0
  92. package/packages/sdk/src/generated/api/core/ApiError.ts +21 -0
  93. package/packages/sdk/src/generated/api/core/ApiRequestOptions.ts +13 -0
  94. package/packages/sdk/src/generated/api/core/ApiResult.ts +7 -0
  95. package/packages/sdk/src/generated/api/core/CancelablePromise.ts +126 -0
  96. package/packages/sdk/src/generated/api/core/OpenAPI.ts +55 -0
  97. package/packages/sdk/src/generated/api/core/request.ts +339 -0
  98. package/packages/sdk/src/generated/api/index.ts +5 -0
  99. package/packages/sdk/src/generated/api/models.ts +219 -0
  100. package/packages/sdk/src/generated/api/services.ts +225 -0
  101. package/packages/sdk/src/hooks/index.ts +21 -0
  102. package/packages/sdk/src/hooks/useAutoJoin.ts +66 -0
  103. package/packages/sdk/src/hooks/useCallActions.ts +28 -0
  104. package/packages/sdk/src/hooks/useCallQuality.ts +416 -0
  105. package/packages/sdk/src/hooks/useCallState.ts +23 -0
  106. package/packages/sdk/src/hooks/useConnection.ts +15 -0
  107. package/packages/sdk/src/hooks/useDevices.ts +296 -0
  108. package/packages/sdk/src/hooks/useErrorRecovery.ts +299 -0
  109. package/packages/sdk/src/hooks/useErrors.ts +84 -0
  110. package/packages/sdk/src/hooks/useEvent.ts +188 -0
  111. package/packages/sdk/src/hooks/useMediaControls.ts +215 -0
  112. package/packages/sdk/src/hooks/useParticipantStatus.ts +318 -0
  113. package/packages/sdk/src/hooks/useParticipants.ts +111 -0
  114. package/packages/sdk/src/index.ts +66 -0
  115. package/packages/sdk/src/livekit/constants.ts +76 -0
  116. package/packages/sdk/src/livekit/device.manager.ts +172 -0
  117. package/packages/sdk/src/livekit/error-classifier.ts +155 -0
  118. package/packages/sdk/src/livekit/events/eventBridge.ts +371 -0
  119. package/packages/sdk/src/livekit/events/trackRegistry.ts +114 -0
  120. package/packages/sdk/src/livekit/index.ts +49 -0
  121. package/packages/sdk/src/livekit/livekit.service.ts +110 -0
  122. package/packages/sdk/src/livekit/media.controls.ts +315 -0
  123. package/packages/sdk/src/livekit/room.manager.ts +79 -0
  124. package/packages/sdk/src/livekit/track.utils.ts +230 -0
  125. package/packages/sdk/src/livekit/types.ts +135 -0
  126. package/packages/sdk/src/provider/RtcProvider.tsx +78 -0
  127. package/packages/sdk/src/services/call-actions.ts +260 -0
  128. package/packages/sdk/src/services/error-recovery.ts +461 -0
  129. package/packages/sdk/src/services/index.ts +2 -0
  130. package/packages/sdk/src/services/sdk-builder.ts +104 -0
  131. package/packages/sdk/src/state/errors.ts +163 -0
  132. package/packages/sdk/src/state/selectors.ts +28 -0
  133. package/packages/sdk/src/state/store.ts +36 -0
  134. package/packages/sdk/src/state/types.ts +151 -0
  135. package/packages/sdk/src/utils/logger.ts +183 -0
  136. package/packages/sdk/tsconfig.json +49 -0
  137. package/packages/sdk/tsup.config.ts +51 -0
  138. package/pnpm-workspace.yaml +4 -0
  139. package/tsconfig.base.json +19 -0
  140. 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
+ }