livekit-client 2.12.0 → 2.13.1

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 (41) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +1 -0
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +145 -56
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  10. package/dist/src/room/Room.d.ts +2 -1
  11. package/dist/src/room/Room.d.ts.map +1 -1
  12. package/dist/src/room/errors.d.ts +2 -1
  13. package/dist/src/room/errors.d.ts.map +1 -1
  14. package/dist/src/room/events.d.ts +11 -1
  15. package/dist/src/room/events.d.ts.map +1 -1
  16. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  17. package/dist/src/room/participant/Participant.d.ts +14 -1
  18. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  19. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  20. package/dist/src/room/track/LocalVideoTrack.d.ts +1 -1
  21. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  22. package/dist/src/room/track/create.d.ts.map +1 -1
  23. package/dist/src/room/utils.d.ts +3 -0
  24. package/dist/src/room/utils.d.ts.map +1 -1
  25. package/dist/ts4.2/src/room/Room.d.ts +2 -1
  26. package/dist/ts4.2/src/room/errors.d.ts +2 -1
  27. package/dist/ts4.2/src/room/events.d.ts +11 -1
  28. package/dist/ts4.2/src/room/participant/Participant.d.ts +14 -1
  29. package/dist/ts4.2/src/room/track/LocalVideoTrack.d.ts +1 -1
  30. package/dist/ts4.2/src/room/utils.d.ts +3 -0
  31. package/package.json +11 -11
  32. package/src/room/RTCEngine.ts +6 -4
  33. package/src/room/Room.ts +14 -6
  34. package/src/room/errors.ts +1 -0
  35. package/src/room/events.ts +12 -0
  36. package/src/room/participant/LocalParticipant.ts +37 -18
  37. package/src/room/participant/Participant.ts +48 -3
  38. package/src/room/participant/publishUtils.ts +4 -0
  39. package/src/room/track/LocalVideoTrack.ts +14 -5
  40. package/src/room/track/create.ts +3 -5
  41. package/src/room/utils.ts +14 -1
package/src/room/Room.ts CHANGED
@@ -103,7 +103,7 @@ import {
103
103
  isLocalParticipant,
104
104
  isReactNative,
105
105
  isRemotePub,
106
- isSafari,
106
+ isSafariBased,
107
107
  isWeb,
108
108
  numberToBigInt,
109
109
  sleep,
@@ -1356,6 +1356,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1356
1356
  // @ts-expect-error setSinkId is not yet in the typescript type of AudioContext
1357
1357
  this.audioContext?.setSinkId(deviceId);
1358
1358
  }
1359
+
1359
1360
  // also set audio output on all audio elements, even if webAudioMix is enabled in order to workaround echo cancellation not working on chrome with non-default output devices
1360
1361
  // see https://issues.chromium.org/issues/40252911#comment7
1361
1362
  await Promise.all(
@@ -1644,6 +1645,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1644
1645
  participant.unpublishTrack(publication.trackSid, true);
1645
1646
  });
1646
1647
  this.emit(RoomEvent.ParticipantDisconnected, participant);
1648
+ participant.setDisconnected();
1647
1649
  this.localParticipant?.handleParticipantDisconnected(participant.identity);
1648
1650
  }
1649
1651
 
@@ -2039,14 +2041,16 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2039
2041
  }
2040
2042
  }
2041
2043
 
2042
- if ((kind === 'audioinput' && !isSafari()) || kind === 'videoinput') {
2044
+ if ((kind === 'audioinput' && !isSafariBased()) || kind === 'videoinput') {
2043
2045
  // airpods on Safari need special handling for audioinput as the track doesn't end as soon as you take them out
2044
2046
  continue;
2045
2047
  }
2046
2048
  // switch to first available device if previously active device is not available any more
2047
2049
  if (
2048
2050
  devicesOfKind.length > 0 &&
2049
- !devicesOfKind.find((deviceInfo) => deviceInfo.deviceId === this.getActiveDevice(kind))
2051
+ !devicesOfKind.find((deviceInfo) => deviceInfo.deviceId === this.getActiveDevice(kind)) &&
2052
+ // avoid switching audio output on safari without explicit user action as it leads to slowed down audio playback
2053
+ (kind !== 'audiooutput' || !isSafariBased())
2050
2054
  ) {
2051
2055
  await this.switchActiveDevice(kind, devicesOfKind[0].deviceId);
2052
2056
  }
@@ -2240,6 +2244,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2240
2244
  status,
2241
2245
  participant,
2242
2246
  );
2247
+ })
2248
+ .on(ParticipantEvent.Active, () => {
2249
+ this.emitWhenConnected(RoomEvent.ParticipantActive, participant);
2243
2250
  });
2244
2251
 
2245
2252
  // update info at the end after callbacks have been set up
@@ -2432,8 +2439,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2432
2439
  this.emit(RoomEvent.ConnectionQualityChanged, quality, this.localParticipant);
2433
2440
  };
2434
2441
 
2435
- private onMediaDevicesError = (e: Error) => {
2436
- this.emit(RoomEvent.MediaDevicesError, e);
2442
+ private onMediaDevicesError = (e: Error, kind?: MediaDeviceKind) => {
2443
+ this.emit(RoomEvent.MediaDevicesError, e, kind);
2437
2444
  };
2438
2445
 
2439
2446
  private onLocalParticipantPermissionsChanged = (prevPermissions?: ParticipantPermission) => {
@@ -2687,7 +2694,7 @@ export type RoomEventCallbacks = {
2687
2694
  publication?: TrackPublication,
2688
2695
  ) => void;
2689
2696
  connectionQualityChanged: (quality: ConnectionQuality, participant: Participant) => void;
2690
- mediaDevicesError: (error: Error) => void;
2697
+ mediaDevicesError: (error: Error, kind?: MediaDeviceKind) => void;
2691
2698
  trackStreamStateChanged: (
2692
2699
  publication: RemoteTrackPublication,
2693
2700
  streamState: Track.StreamState,
@@ -2714,4 +2721,5 @@ export type RoomEventCallbacks = {
2714
2721
  chatMessage: (message: ChatMessage, participant?: RemoteParticipant | LocalParticipant) => void;
2715
2722
  localTrackSubscribed: (publication: LocalTrackPublication, participant: LocalParticipant) => void;
2716
2723
  metricsReceived: (metrics: MetricsBatch, participant?: Participant) => void;
2724
+ participantActive: (participant: Participant) => void;
2717
2725
  };
@@ -16,6 +16,7 @@ export enum ConnectionErrorReason {
16
16
  InternalError,
17
17
  Cancelled,
18
18
  LeaveRequest,
19
+ Timeout,
19
20
  }
20
21
 
21
22
  export class ConnectionError extends LivekitError {
@@ -203,6 +203,13 @@ export enum RoomEvent {
203
203
  */
204
204
  ParticipantAttributesChanged = 'participantAttributesChanged',
205
205
 
206
+ /**
207
+ * Emitted when the participant's state changes to ACTIVE and is ready to send/receive data messages
208
+ *
209
+ * args: (participant: [[Participant]])
210
+ */
211
+ ParticipantActive = 'participantActive',
212
+
206
213
  /**
207
214
  * Room metadata is a simple way for app-specific state to be pushed to
208
215
  * all users.
@@ -540,6 +547,11 @@ export enum ParticipantEvent {
540
547
 
541
548
  /** only emitted on local participant */
542
549
  ChatMessage = 'chatMessage',
550
+
551
+ /**
552
+ * Emitted when the participant's state changes to ACTIVE and is ready to send/receive data messages
553
+ */
554
+ Active = 'active',
543
555
  }
544
556
 
545
557
  /** @internal */
@@ -71,6 +71,7 @@ import {
71
71
  mergeDefaultOptions,
72
72
  mimeTypeToVideoCodecString,
73
73
  screenCaptureToDisplayMediaStreamOptions,
74
+ sourceToKind,
74
75
  } from '../track/utils';
75
76
  import {
76
77
  type ByteStreamInfo,
@@ -517,7 +518,7 @@ export default class LocalParticipant extends Participant {
517
518
  tr.stop();
518
519
  });
519
520
  if (e instanceof Error) {
520
- this.emit(ParticipantEvent.MediaDevicesError, e);
521
+ this.emit(ParticipantEvent.MediaDevicesError, e, sourceToKind(source));
521
522
  }
522
523
  this.pendingPublishing.delete(source);
523
524
  throw e;
@@ -1142,11 +1143,32 @@ export default class LocalParticipant extends Participant {
1142
1143
  };
1143
1144
 
1144
1145
  let ti: TrackInfo;
1146
+ const addTrackPromise = new Promise<TrackInfo>(async (resolve, reject) => {
1147
+ try {
1148
+ ti = await this.engine.addTrack(req);
1149
+ resolve(ti);
1150
+ } catch (err) {
1151
+ if (track.sender && this.engine.pcManager?.publisher) {
1152
+ this.engine.pcManager.publisher.removeTrack(track.sender);
1153
+ await this.engine.negotiate().catch((negotiateErr) => {
1154
+ this.log.error(
1155
+ 'failed to negotiate after removing track due to failed add track request',
1156
+ {
1157
+ ...this.logContext,
1158
+ ...getLogContextFromTrack(track),
1159
+ error: negotiateErr,
1160
+ },
1161
+ );
1162
+ });
1163
+ }
1164
+ reject(err);
1165
+ }
1166
+ });
1145
1167
  if (this.enabledPublishVideoCodecs.length > 0) {
1146
- const rets = await Promise.all([this.engine.addTrack(req), negotiate()]);
1168
+ const rets = await Promise.all([addTrackPromise, negotiate()]);
1147
1169
  ti = rets[0];
1148
1170
  } else {
1149
- ti = await this.engine.addTrack(req);
1171
+ ti = await addTrackPromise;
1150
1172
  // server might not support the codec the client has requested, in that case, fallback
1151
1173
  // to a supported codec
1152
1174
  let primaryCodecMime: string | undefined;
@@ -1780,6 +1802,7 @@ export default class LocalParticipant extends Participant {
1780
1802
  streamId,
1781
1803
  topic: info.topic,
1782
1804
  timestamp: numberToBigInt(Date.now()),
1805
+ attributes: info.attributes,
1783
1806
  contentHeader: {
1784
1807
  case: 'byteHeader',
1785
1808
  value: new DataStream_ByteHeader({
@@ -2153,22 +2176,18 @@ export default class LocalParticipant extends Participant {
2153
2176
  });
2154
2177
  return;
2155
2178
  }
2156
- if (update.subscribedCodecs.length > 0) {
2157
- if (!pub.videoTrack) {
2158
- return;
2159
- }
2160
- const newCodecs = await pub.videoTrack.setPublishingCodecs(update.subscribedCodecs);
2161
- for await (const codec of newCodecs) {
2162
- if (isBackupCodec(codec)) {
2163
- this.log.debug(`publish ${codec} for ${pub.videoTrack.sid}`, {
2164
- ...this.logContext,
2165
- ...getLogContextFromTrack(pub),
2166
- });
2167
- await this.publishAdditionalCodecForTrack(pub.videoTrack, codec, pub.options);
2168
- }
2179
+ if (!pub.videoTrack) {
2180
+ return;
2181
+ }
2182
+ const newCodecs = await pub.videoTrack.setPublishingCodecs(update.subscribedCodecs);
2183
+ for await (const codec of newCodecs) {
2184
+ if (isBackupCodec(codec)) {
2185
+ this.log.debug(`publish ${codec} for ${pub.videoTrack.sid}`, {
2186
+ ...this.logContext,
2187
+ ...getLogContextFromTrack(pub),
2188
+ });
2189
+ await this.publishAdditionalCodecForTrack(pub.videoTrack, codec, pub.options);
2169
2190
  }
2170
- } else if (update.subscribedQualities.length > 0) {
2171
- await pub.videoTrack?.setPublishingLayers(update.subscribedQualities);
2172
2191
  }
2173
2192
  };
2174
2193
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DataPacket_Kind,
3
3
  ParticipantInfo,
4
+ ParticipantInfo_State,
4
5
  ParticipantInfo_Kind as ParticipantKind,
5
6
  ParticipantPermission,
6
7
  ConnectionQuality as ProtoQuality,
@@ -18,7 +19,7 @@ import { Track } from '../track/Track';
18
19
  import type { TrackPublication } from '../track/TrackPublication';
19
20
  import { diffAttributes } from '../track/utils';
20
21
  import type { ChatMessage, LoggerOptions, TranscriptionSegment } from '../types';
21
- import { isAudioTrack } from '../utils';
22
+ import { Future, isAudioTrack } from '../utils';
22
23
 
23
24
  export enum ConnectionQuality {
24
25
  Excellent = 'excellent',
@@ -93,6 +94,8 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
93
94
 
94
95
  protected loggerOptions?: LoggerOptions;
95
96
 
97
+ protected activeFuture?: Future<void>;
98
+
96
99
  protected get logContext() {
97
100
  return {
98
101
  ...this.loggerOptions?.loggerContextCb?.(),
@@ -110,6 +113,10 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
110
113
  return this.permissions?.agent || this.kind === ParticipantKind.AGENT;
111
114
  }
112
115
 
116
+ get isActive() {
117
+ return this.participantInfo?.state === ParticipantInfo_State.ACTIVE;
118
+ }
119
+
113
120
  get kind() {
114
121
  return this._kind;
115
122
  }
@@ -173,6 +180,28 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
173
180
  }
174
181
  }
175
182
 
183
+ /**
184
+ * Waits until the participant is active and ready to receive data messages
185
+ * @returns a promise that resolves when the participant is active
186
+ */
187
+ waitUntilActive(): Promise<void> {
188
+ if (this.isActive) {
189
+ return Promise.resolve();
190
+ }
191
+
192
+ if (this.activeFuture) {
193
+ return this.activeFuture.promise;
194
+ }
195
+
196
+ this.activeFuture = new Future<void>();
197
+
198
+ this.once(ParticipantEvent.Active, () => {
199
+ this.activeFuture?.resolve?.();
200
+ this.activeFuture = undefined;
201
+ });
202
+ return this.activeFuture.promise;
203
+ }
204
+
176
205
  get connectionQuality(): ConnectionQuality {
177
206
  return this._connectionQuality;
178
207
  }
@@ -224,12 +253,17 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
224
253
  this._setName(info.name);
225
254
  this._setMetadata(info.metadata);
226
255
  this._setAttributes(info.attributes);
256
+ if (
257
+ info.state === ParticipantInfo_State.ACTIVE &&
258
+ this.participantInfo?.state !== ParticipantInfo_State.ACTIVE
259
+ ) {
260
+ this.emit(ParticipantEvent.Active);
261
+ }
227
262
  if (info.permission) {
228
263
  this.setPermissions(info.permission);
229
264
  }
230
265
  // set this last so setMetadata can detect changes
231
266
  this.participantInfo = info;
232
- this.log.trace('update participant info', { ...this.logContext, info });
233
267
  return true;
234
268
  }
235
269
 
@@ -310,6 +344,16 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
310
344
  }
311
345
  }
312
346
 
347
+ /**
348
+ * @internal
349
+ */
350
+ setDisconnected() {
351
+ if (this.activeFuture) {
352
+ this.activeFuture.reject?.(new Error('Participant disconnected'));
353
+ this.activeFuture = undefined;
354
+ }
355
+ }
356
+
313
357
  /**
314
358
  * @internal
315
359
  */
@@ -377,7 +421,7 @@ export type ParticipantEventCallbacks = {
377
421
  publication: RemoteTrackPublication,
378
422
  status: TrackPublication.PermissionStatus,
379
423
  ) => void;
380
- mediaDevicesError: (error: Error) => void;
424
+ mediaDevicesError: (error: Error, kind?: MediaDeviceKind) => void;
381
425
  audioStreamAcquired: () => void;
382
426
  participantPermissionsChanged: (prevPermissions?: ParticipantPermission) => void;
383
427
  trackSubscriptionStatusChanged: (
@@ -387,4 +431,5 @@ export type ParticipantEventCallbacks = {
387
431
  attributesChanged: (changedAttributes: Record<string, string>) => void;
388
432
  localTrackSubscribed: (trackPublication: LocalTrackPublication) => void;
389
433
  chatMessage: (msg: ChatMessage) => void;
434
+ active: () => void;
390
435
  };
@@ -19,6 +19,7 @@ import {
19
19
  isReactNative,
20
20
  isSVCCodec,
21
21
  isSafari,
22
+ isSafariSvcApi,
22
23
  unwrapConstraint,
23
24
  } from '../utils';
24
25
 
@@ -158,12 +159,15 @@ export function computeVideoEncodings(
158
159
  (browser?.name === 'Chrome' && compareVersions(browser?.version, '113') < 0)
159
160
  ) {
160
161
  const bitratesRatio = sm.suffix == 'h' ? 2 : 3;
162
+ // safari 18.4 uses a different svc API that requires scaleResolutionDownBy to be set.
163
+ const requireScale = isSafariSvcApi(browser);
161
164
  for (let i = 0; i < sm.spatial; i += 1) {
162
165
  // in legacy SVC, scaleResolutionDownBy cannot be set
163
166
  encodings.push({
164
167
  rid: videoRids[2 - i],
165
168
  maxBitrate: videoEncoding.maxBitrate / bitratesRatio ** i,
166
169
  maxFramerate: original.encoding.maxFramerate,
170
+ scaleResolutionDownBy: requireScale ? 2 ** i : undefined,
167
171
  });
168
172
  }
169
173
  // legacy SVC, scalabilityMode is set only on the first encoding
@@ -12,7 +12,7 @@ import { ScalabilityMode } from '../participant/publishUtils';
12
12
  import type { VideoSenderStats } from '../stats';
13
13
  import { computeBitrate, monitorFrequency } from '../stats';
14
14
  import type { LoggerOptions } from '../types';
15
- import { compareVersions, isFireFox, isMobile, isWeb } from '../utils';
15
+ import { compareVersions, isFireFox, isMobile, isSVCCodec, isWeb } from '../utils';
16
16
  import LocalTrack from './LocalTrack';
17
17
  import { Track, VideoQuality } from './Track';
18
18
  import type { VideoCaptureOptions, VideoCodec } from './options';
@@ -239,7 +239,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
239
239
  );
240
240
  }
241
241
  this.log.debug(`setting publishing quality. max quality ${maxQuality}`, this.logContext);
242
- this.setPublishingLayers(qualities);
242
+ this.setPublishingLayers(isSVCCodec(this.codec), qualities);
243
243
  }
244
244
 
245
245
  async restartTrack(options?: VideoCaptureOptions) {
@@ -334,7 +334,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
334
334
  });
335
335
  // only enable simulcast codec for preference codec setted
336
336
  if (!this.codec && codecs.length > 0) {
337
- await this.setPublishingLayers(codecs[0].qualities);
337
+ await this.setPublishingLayers(isSVCCodec(codecs[0].codec), codecs[0].qualities);
338
338
  return [];
339
339
  }
340
340
 
@@ -343,7 +343,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
343
343
  const newCodecs: VideoCodec[] = [];
344
344
  for await (const codec of codecs) {
345
345
  if (!this.codec || this.codec === codec.codec) {
346
- await this.setPublishingLayers(codec.qualities);
346
+ await this.setPublishingLayers(isSVCCodec(codec.codec), codec.qualities);
347
347
  } else {
348
348
  const simulcastCodecInfo = this.simulcastCodecs.get(codec.codec as VideoCodec);
349
349
  this.log.debug(`try setPublishingCodec for ${codec.codec}`, {
@@ -364,6 +364,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
364
364
  simulcastCodecInfo.encodings!,
365
365
  codec.qualities,
366
366
  this.senderLock,
367
+ isSVCCodec(codec.codec),
367
368
  this.log,
368
369
  this.logContext,
369
370
  );
@@ -377,7 +378,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
377
378
  * @internal
378
379
  * Sets layers that should be publishing
379
380
  */
380
- async setPublishingLayers(qualities: SubscribedQuality[]) {
381
+ async setPublishingLayers(isSvc: boolean, qualities: SubscribedQuality[]) {
381
382
  this.log.debug('setting publishing layers', { ...this.logContext, qualities });
382
383
  if (!this.sender || !this.encodings) {
383
384
  return;
@@ -388,6 +389,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
388
389
  this.encodings,
389
390
  qualities,
390
391
  this.senderLock,
392
+ isSvc,
391
393
  this.log,
392
394
  this.logContext,
393
395
  );
@@ -434,6 +436,7 @@ async function setPublishingLayersForSender(
434
436
  senderEncodings: RTCRtpEncodingParameters[],
435
437
  qualities: SubscribedQuality[],
436
438
  senderLock: Mutex,
439
+ isSVC: boolean,
437
440
  log: StructuredLogger,
438
441
  logContext: Record<string, unknown>,
439
442
  ) {
@@ -498,6 +501,12 @@ async function setPublishingLayersForSender(
498
501
  }
499
502
  }
500
503
  } else {
504
+ if (isSVC) {
505
+ const hasEnabledEncoding = qualities.some((q) => q.enabled);
506
+ if (hasEnabledEncoding) {
507
+ qualities.forEach((q) => (q.enabled = true));
508
+ }
509
+ }
501
510
  // simulcast dynacast encodings
502
511
  encodings.forEach((encoding, idx) => {
503
512
  let rid = encoding.rid ?? '';
@@ -78,18 +78,16 @@ export async function createLocalTracks(
78
78
  deviceId: { ideal: deviceId },
79
79
  };
80
80
  }
81
- // TODO if internal options don't have device Id specified, set it to 'default'
82
81
  if (
83
82
  internalOptions.audio === true ||
84
83
  (typeof internalOptions.audio === 'object' && !internalOptions.audio.deviceId)
85
84
  ) {
86
85
  internalOptions.audio = { deviceId: 'default' };
87
86
  }
88
- if (
89
- internalOptions.video === true ||
90
- (typeof internalOptions.video === 'object' && !internalOptions.video.deviceId)
91
- ) {
87
+ if (internalOptions.video === true) {
92
88
  internalOptions.video = { deviceId: 'default' };
89
+ } else if (typeof internalOptions.video === 'object' && !internalOptions.video.deviceId) {
90
+ internalOptions.video.deviceId = 'default';
93
91
  }
94
92
  const opts = mergeDefaultOptions(internalOptions, audioDefaults, videoDefaults);
95
93
  const constraints = constraintsForOptions(opts);
package/src/room/utils.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  DisconnectReason,
6
6
  Transcription as TranscriptionModel,
7
7
  } from '@livekit/protocol';
8
- import { getBrowser } from '../utils/browserParser';
8
+ import { type BrowserDetails, getBrowser } from '../utils/browserParser';
9
9
  import { protocolVersion, version } from '../version';
10
10
  import { type ConnectionError, ConnectionErrorReason } from './errors';
11
11
  import type LocalParticipant from './participant/LocalParticipant';
@@ -143,11 +143,24 @@ export function isSafari(): boolean {
143
143
  return getBrowser()?.name === 'Safari';
144
144
  }
145
145
 
146
+ export function isSafariBased(): boolean {
147
+ const b = getBrowser();
148
+ return b?.name === 'Safari' || b?.os === 'iOS';
149
+ }
150
+
146
151
  export function isSafari17(): boolean {
147
152
  const b = getBrowser();
148
153
  return b?.name === 'Safari' && b.version.startsWith('17.');
149
154
  }
150
155
 
156
+ export function isSafariSvcApi(browser?: BrowserDetails): boolean {
157
+ if (!browser) {
158
+ browser = getBrowser();
159
+ }
160
+ // Safari 18.4 requires legacy svc api and scaleResolutionDown to be set
161
+ return browser?.name === 'Safari' && compareVersions(browser.version, '18.3') > 0;
162
+ }
163
+
151
164
  export function isMobile(): boolean {
152
165
  if (!isWeb()) return false;
153
166