livekit-client 2.13.8 → 2.15.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 (64) hide show
  1. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  2. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  3. package/dist/livekit-client.esm.mjs +276 -117
  4. package/dist/livekit-client.esm.mjs.map +1 -1
  5. package/dist/livekit-client.umd.js +1 -1
  6. package/dist/livekit-client.umd.js.map +1 -1
  7. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  8. package/dist/src/room/PCTransport.d.ts +1 -0
  9. package/dist/src/room/PCTransport.d.ts.map +1 -1
  10. package/dist/src/room/Room.d.ts.map +1 -1
  11. package/dist/src/room/events.d.ts +18 -0
  12. package/dist/src/room/events.d.ts.map +1 -1
  13. package/dist/src/room/participant/LocalParticipant.d.ts +1 -0
  14. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  15. package/dist/src/room/participant/Participant.d.ts +3 -0
  16. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  17. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  18. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  19. package/dist/src/room/track/LocalTrackPublication.d.ts +1 -0
  20. package/dist/src/room/track/LocalTrackPublication.d.ts.map +1 -1
  21. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  22. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  23. package/dist/src/room/track/RemoteTrackPublication.d.ts +12 -3
  24. package/dist/src/room/track/RemoteTrackPublication.d.ts.map +1 -1
  25. package/dist/src/room/track/Track.d.ts +1 -0
  26. package/dist/src/room/track/Track.d.ts.map +1 -1
  27. package/dist/src/room/track/TrackPublication.d.ts +1 -0
  28. package/dist/src/room/track/TrackPublication.d.ts.map +1 -1
  29. package/dist/src/room/track/options.d.ts +5 -1
  30. package/dist/src/room/track/options.d.ts.map +1 -1
  31. package/dist/src/room/track/utils.d.ts +3 -1
  32. package/dist/src/room/track/utils.d.ts.map +1 -1
  33. package/dist/src/room/utils.d.ts +2 -1
  34. package/dist/src/room/utils.d.ts.map +1 -1
  35. package/dist/ts4.2/src/room/PCTransport.d.ts +1 -0
  36. package/dist/ts4.2/src/room/events.d.ts +18 -0
  37. package/dist/ts4.2/src/room/participant/LocalParticipant.d.ts +1 -0
  38. package/dist/ts4.2/src/room/participant/Participant.d.ts +3 -0
  39. package/dist/ts4.2/src/room/track/LocalTrackPublication.d.ts +1 -0
  40. package/dist/ts4.2/src/room/track/LocalVideoTrack.d.ts +7 -0
  41. package/dist/ts4.2/src/room/track/RemoteTrackPublication.d.ts +12 -3
  42. package/dist/ts4.2/src/room/track/Track.d.ts +1 -0
  43. package/dist/ts4.2/src/room/track/TrackPublication.d.ts +1 -0
  44. package/dist/ts4.2/src/room/track/options.d.ts +6 -1
  45. package/dist/ts4.2/src/room/track/utils.d.ts +3 -1
  46. package/dist/ts4.2/src/room/utils.d.ts +2 -1
  47. package/package.json +11 -11
  48. package/src/e2ee/E2eeManager.ts +3 -2
  49. package/src/room/PCTransport.ts +88 -77
  50. package/src/room/Room.ts +2 -0
  51. package/src/room/events.ts +21 -0
  52. package/src/room/participant/LocalParticipant.ts +14 -2
  53. package/src/room/participant/Participant.ts +3 -0
  54. package/src/room/participant/RemoteParticipant.ts +1 -0
  55. package/src/room/participant/publishUtils.ts +3 -2
  56. package/src/room/track/LocalTrackPublication.ts +9 -1
  57. package/src/room/track/LocalVideoTrack.ts +68 -1
  58. package/src/room/track/RemoteTrackPublication.ts +91 -32
  59. package/src/room/track/Track.ts +1 -0
  60. package/src/room/track/TrackPublication.ts +1 -0
  61. package/src/room/track/create.ts +2 -2
  62. package/src/room/track/options.ts +6 -1
  63. package/src/room/track/utils.ts +12 -1
  64. package/src/room/utils.ts +37 -3
@@ -7,6 +7,7 @@ import {
7
7
  } from '@livekit/protocol';
8
8
  import type { SignalClient } from '../../api/SignalClient';
9
9
  import type { StructuredLogger } from '../../logger';
10
+ import { TrackEvent } from '../events';
10
11
  import { ScalabilityMode } from '../participant/publishUtils';
11
12
  import type { VideoSenderStats } from '../stats';
12
13
  import { computeBitrate, monitorFrequency } from '../stats';
@@ -56,6 +57,10 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
56
57
 
57
58
  private degradationPreference: RTCDegradationPreference = 'balanced';
58
59
 
60
+ private isCpuConstrained: boolean = false;
61
+
62
+ private optimizeForPerformance: boolean = false;
63
+
59
64
  get sender(): RTCRtpSender | undefined {
60
65
  return this._sender;
61
66
  }
@@ -251,6 +256,9 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
251
256
  }
252
257
  await this.restart(constraints);
253
258
 
259
+ // reset cpu constrained state after track is restarted
260
+ this.isCpuConstrained = false;
261
+
254
262
  for await (const sc of this.simulcastCodecs.values()) {
255
263
  if (sc.sender && sc.sender.transport?.state !== 'closed') {
256
264
  sc.mediaStreamTrack = this.mediaStreamTrack.clone();
@@ -334,6 +342,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
334
342
  // only enable simulcast codec for preference codec setted
335
343
  if (!this.codec && codecs.length > 0) {
336
344
  await this.setPublishingLayers(isSVCCodec(codecs[0].codec), codecs[0].qualities);
345
+
337
346
  return [];
338
347
  }
339
348
 
@@ -378,6 +387,13 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
378
387
  * Sets layers that should be publishing
379
388
  */
380
389
  async setPublishingLayers(isSvc: boolean, qualities: SubscribedQuality[]) {
390
+ if (this.optimizeForPerformance) {
391
+ this.log.info('skipping setPublishingLayers due to optimized publishing performance', {
392
+ ...this.logContext,
393
+ qualities,
394
+ });
395
+ return;
396
+ }
381
397
  this.log.debug('setting publishing layers', { ...this.logContext, qualities });
382
398
  if (!this.sender || !this.encodings) {
383
399
  return;
@@ -394,6 +410,49 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
394
410
  );
395
411
  }
396
412
 
413
+ /**
414
+ * Designed for lower powered devices, reduces video publishing quality and disables simulcast.
415
+ * @experimental
416
+ */
417
+ async prioritizePerformance() {
418
+ if (!this.sender) {
419
+ throw new Error('sender not found');
420
+ }
421
+
422
+ const unlock = await this.senderLock.lock();
423
+
424
+ try {
425
+ this.optimizeForPerformance = true;
426
+ const params = this.sender.getParameters();
427
+
428
+ params.encodings = params.encodings.map((e, idx) => ({
429
+ ...e,
430
+ active: idx === 0,
431
+ scaleResolutionDownBy: Math.max(
432
+ 1,
433
+ Math.ceil((this.mediaStreamTrack.getSettings().height ?? 360) / 360),
434
+ ),
435
+ scalabilityMode: idx === 0 && isSVCCodec(this.codec) ? 'L1T3' : undefined,
436
+ maxFramerate: idx === 0 ? 15 : 0,
437
+ maxBitrate: idx === 0 ? e.maxBitrate : 0,
438
+ }));
439
+ this.log.debug('setting performance optimised encodings', {
440
+ ...this.logContext,
441
+ encodings: params.encodings,
442
+ });
443
+ this.encodings = params.encodings;
444
+ await this.sender.setParameters(params);
445
+ } catch (e) {
446
+ this.log.error('failed to set performance optimised encodings', {
447
+ ...this.logContext,
448
+ error: e,
449
+ });
450
+ this.optimizeForPerformance = false;
451
+ } finally {
452
+ unlock();
453
+ }
454
+ }
455
+
397
456
  protected monitorSender = async () => {
398
457
  if (!this.sender) {
399
458
  this._currentBitrate = 0;
@@ -404,11 +463,19 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
404
463
  try {
405
464
  stats = await this.getSenderStats();
406
465
  } catch (e) {
407
- this.log.error('could not get audio sender stats', { ...this.logContext, error: e });
466
+ this.log.error('could not get video sender stats', { ...this.logContext, error: e });
408
467
  return;
409
468
  }
410
469
  const statsMap = new Map<string, VideoSenderStats>(stats.map((s) => [s.rid, s]));
411
470
 
471
+ const isCpuConstrained = stats.some((s) => s.qualityLimitationReason === 'cpu');
472
+ if (isCpuConstrained !== this.isCpuConstrained) {
473
+ this.isCpuConstrained = isCpuConstrained;
474
+ if (this.isCpuConstrained) {
475
+ this.emit(TrackEvent.CpuConstrained);
476
+ }
477
+ }
478
+
412
479
  if (this.prevStats) {
413
480
  let totalBitrate = 0;
414
481
  statsMap.forEach((s, key) => {
@@ -11,6 +11,7 @@ import { isRemoteVideoTrack } from '../utils';
11
11
  import type RemoteTrack from './RemoteTrack';
12
12
  import { Track, VideoQuality } from './Track';
13
13
  import { TrackPublication } from './TrackPublication';
14
+ import { areDimensionsSmaller, layerDimensionsFor } from './utils';
14
15
 
15
16
  export default class RemoteTrackPublication extends TrackPublication {
16
17
  track?: RemoteTrack = undefined;
@@ -21,11 +22,15 @@ export default class RemoteTrackPublication extends TrackPublication {
21
22
  // keeps track of client's desire to subscribe to a track, also true if autoSubscribe is active
22
23
  protected subscribed?: boolean;
23
24
 
24
- protected disabled: boolean = false;
25
+ protected requestedDisabled: boolean | undefined = undefined;
25
26
 
26
- protected currentVideoQuality?: VideoQuality = VideoQuality.HIGH;
27
+ protected visible: boolean = true;
27
28
 
28
- protected videoDimensions?: Track.Dimensions;
29
+ protected videoDimensionsAdaptiveStream?: Track.Dimensions;
30
+
31
+ protected requestedVideoDimensions?: Track.Dimensions;
32
+
33
+ protected requestedMaxQuality?: VideoQuality;
29
34
 
30
35
  protected fps?: number;
31
36
 
@@ -105,7 +110,11 @@ export default class RemoteTrackPublication extends TrackPublication {
105
110
  }
106
111
 
107
112
  get isEnabled(): boolean {
108
- return !this.disabled;
113
+ return this.requestedDisabled !== undefined
114
+ ? !this.requestedDisabled
115
+ : this.isAdaptiveStream
116
+ ? this.visible
117
+ : true;
109
118
  }
110
119
 
111
120
  get isLocal() {
@@ -119,10 +128,10 @@ export default class RemoteTrackPublication extends TrackPublication {
119
128
  * @param enabled
120
129
  */
121
130
  setEnabled(enabled: boolean) {
122
- if (!this.isManualOperationAllowed() || this.disabled === !enabled) {
131
+ if (!this.isManualOperationAllowed() || this.requestedDisabled === !enabled) {
123
132
  return;
124
133
  }
125
- this.disabled = !enabled;
134
+ this.requestedDisabled = !enabled;
126
135
 
127
136
  this.emitTrackUpdate();
128
137
  }
@@ -135,29 +144,36 @@ export default class RemoteTrackPublication extends TrackPublication {
135
144
  * optimize for uninterrupted video
136
145
  */
137
146
  setVideoQuality(quality: VideoQuality) {
138
- if (!this.isManualOperationAllowed() || this.currentVideoQuality === quality) {
147
+ if (!this.isManualOperationAllowed() || this.requestedMaxQuality === quality) {
139
148
  return;
140
149
  }
141
- this.currentVideoQuality = quality;
142
- this.videoDimensions = undefined;
150
+ this.requestedMaxQuality = quality;
151
+ this.requestedVideoDimensions = undefined;
143
152
 
144
153
  this.emitTrackUpdate();
145
154
  }
146
155
 
156
+ /**
157
+ * Explicitly set the video dimensions for this track.
158
+ *
159
+ * This will take precedence over adaptive stream dimensions.
160
+ *
161
+ * @param dimensions The video dimensions to set.
162
+ */
147
163
  setVideoDimensions(dimensions: Track.Dimensions) {
148
164
  if (!this.isManualOperationAllowed()) {
149
165
  return;
150
166
  }
151
167
  if (
152
- this.videoDimensions?.width === dimensions.width &&
153
- this.videoDimensions?.height === dimensions.height
168
+ this.requestedVideoDimensions?.width === dimensions.width &&
169
+ this.requestedVideoDimensions?.height === dimensions.height
154
170
  ) {
155
171
  return;
156
172
  }
157
173
  if (isRemoteVideoTrack(this.track)) {
158
- this.videoDimensions = dimensions;
174
+ this.requestedVideoDimensions = dimensions;
159
175
  }
160
- this.currentVideoQuality = undefined;
176
+ this.requestedMaxQuality = undefined;
161
177
 
162
178
  this.emitTrackUpdate();
163
179
  }
@@ -180,7 +196,7 @@ export default class RemoteTrackPublication extends TrackPublication {
180
196
  }
181
197
 
182
198
  get videoQuality(): VideoQuality | undefined {
183
- return this.currentVideoQuality;
199
+ return this.requestedMaxQuality ?? VideoQuality.HIGH;
184
200
  }
185
201
 
186
202
  /** @internal */
@@ -260,13 +276,6 @@ export default class RemoteTrackPublication extends TrackPublication {
260
276
  }
261
277
 
262
278
  private isManualOperationAllowed(): boolean {
263
- if (this.kind === Track.Kind.Video && this.isAdaptiveStream) {
264
- this.log.warn(
265
- 'adaptive stream is enabled, cannot change video track settings',
266
- this.logContext,
267
- );
268
- return false;
269
- }
270
279
  if (!this.isDesired) {
271
280
  this.log.warn('cannot update track settings when not subscribed', this.logContext);
272
281
  return false;
@@ -288,7 +297,7 @@ export default class RemoteTrackPublication extends TrackPublication {
288
297
  `adaptivestream video visibility ${this.trackSid}, visible=${visible}`,
289
298
  this.logContext,
290
299
  );
291
- this.disabled = !visible;
300
+ this.visible = visible;
292
301
  this.emitTrackUpdate();
293
302
  };
294
303
 
@@ -297,7 +306,7 @@ export default class RemoteTrackPublication extends TrackPublication {
297
306
  `adaptivestream video dimensions ${dimensions.width}x${dimensions.height}`,
298
307
  this.logContext,
299
308
  );
300
- this.videoDimensions = dimensions;
309
+ this.videoDimensionsAdaptiveStream = dimensions;
301
310
  this.emitTrackUpdate();
302
311
  };
303
312
 
@@ -305,17 +314,67 @@ export default class RemoteTrackPublication extends TrackPublication {
305
314
  emitTrackUpdate() {
306
315
  const settings: UpdateTrackSettings = new UpdateTrackSettings({
307
316
  trackSids: [this.trackSid],
308
- disabled: this.disabled,
317
+ disabled: !this.isEnabled,
309
318
  fps: this.fps,
310
319
  });
311
- if (this.videoDimensions) {
312
- settings.width = Math.ceil(this.videoDimensions.width);
313
- settings.height = Math.ceil(this.videoDimensions.height);
314
- } else if (this.currentVideoQuality !== undefined) {
315
- settings.quality = this.currentVideoQuality;
316
- } else {
317
- // defaults to high quality
318
- settings.quality = VideoQuality.HIGH;
320
+
321
+ if (this.kind === Track.Kind.Video) {
322
+ let minDimensions = this.requestedVideoDimensions;
323
+
324
+ if (this.videoDimensionsAdaptiveStream !== undefined) {
325
+ if (minDimensions) {
326
+ // check whether the adaptive stream dimensions are smaller than the requested dimensions and use smaller one
327
+ const smallerAdaptive = areDimensionsSmaller(
328
+ this.videoDimensionsAdaptiveStream,
329
+ minDimensions,
330
+ );
331
+ if (smallerAdaptive) {
332
+ this.log.debug('using adaptive stream dimensions instead of requested', {
333
+ ...this.logContext,
334
+ ...this.videoDimensionsAdaptiveStream,
335
+ });
336
+ minDimensions = this.videoDimensionsAdaptiveStream;
337
+ }
338
+ } else if (this.requestedMaxQuality !== undefined && this.trackInfo) {
339
+ // check whether adaptive stream dimensions are smaller than the max quality layer and use smaller one
340
+ const maxQualityLayer = layerDimensionsFor(this.trackInfo, this.requestedMaxQuality);
341
+
342
+ if (
343
+ maxQualityLayer &&
344
+ areDimensionsSmaller(this.videoDimensionsAdaptiveStream, maxQualityLayer)
345
+ ) {
346
+ this.log.debug('using adaptive stream dimensions instead of max quality layer', {
347
+ ...this.logContext,
348
+ ...this.videoDimensionsAdaptiveStream,
349
+ });
350
+ minDimensions = this.videoDimensionsAdaptiveStream;
351
+ }
352
+ } else {
353
+ this.log.debug('using adaptive stream dimensions', {
354
+ ...this.logContext,
355
+ ...this.videoDimensionsAdaptiveStream,
356
+ });
357
+ minDimensions = this.videoDimensionsAdaptiveStream;
358
+ }
359
+ }
360
+
361
+ if (minDimensions) {
362
+ settings.width = Math.ceil(minDimensions.width);
363
+ settings.height = Math.ceil(minDimensions.height);
364
+ } else if (this.requestedMaxQuality !== undefined) {
365
+ this.log.debug('using requested max quality', {
366
+ ...this.logContext,
367
+ quality: this.requestedMaxQuality,
368
+ });
369
+ settings.quality = this.requestedMaxQuality;
370
+ } else {
371
+ this.log.debug('using default quality', {
372
+ ...this.logContext,
373
+ quality: VideoQuality.HIGH,
374
+ });
375
+ // defaults to high quality
376
+ settings.quality = VideoQuality.HIGH;
377
+ }
319
378
  }
320
379
 
321
380
  this.emit(TrackEvent.UpdateSettings, settings);
@@ -529,4 +529,5 @@ export type TrackEventCallbacks = {
529
529
  audioTrackFeatureUpdate: (track: any, feature: AudioTrackFeature, enabled: boolean) => void;
530
530
  timeSyncUpdate: (update: { timestamp: number; rtpTimestamp: number }) => void;
531
531
  preConnectBufferFlushed: (buffer: Uint8Array[]) => void;
532
+ cpuConstrained: () => void;
532
533
  };
@@ -179,4 +179,5 @@ export type PublicationEventCallbacks = {
179
179
  subscriptionFailed: (error: SubscriptionError) => void;
180
180
  transcriptionReceived: (transcription: TranscriptionSegment[]) => void;
181
181
  timeSyncUpdate: (timestamp: number) => void;
182
+ cpuConstrained: (track: LocalVideoTrack) => void;
182
183
  };
@@ -3,7 +3,7 @@ import { audioDefaults, videoDefaults } from '../defaults';
3
3
  import { DeviceUnsupportedError, TrackInvalidError } from '../errors';
4
4
  import { mediaTrackToLocalTrack } from '../participant/publishUtils';
5
5
  import type { LoggerOptions } from '../types';
6
- import { isAudioTrack, isSafari17, isVideoTrack, unwrapConstraint } from '../utils';
6
+ import { isAudioTrack, isSafari17Based, isVideoTrack, unwrapConstraint } from '../utils';
7
7
  import LocalAudioTrack from './LocalAudioTrack';
8
8
  import type LocalTrack from './LocalTrack';
9
9
  import LocalVideoTrack from './LocalVideoTrack';
@@ -198,7 +198,7 @@ export async function createLocalScreenTracks(
198
198
  if (options === undefined) {
199
199
  options = {};
200
200
  }
201
- if (options.resolution === undefined && !isSafari17()) {
201
+ if (options.resolution === undefined && !isSafari17Based()) {
202
202
  options.resolution = ScreenSharePresets.h1080fps30.resolution;
203
203
  }
204
204
 
@@ -173,6 +173,11 @@ export interface VideoCaptureOptions {
173
173
  */
174
174
  deviceId?: ConstrainDOMString;
175
175
 
176
+ /**
177
+ * A ConstrainDouble specifying the frame rate or range of frame rates which are acceptable and/or required.
178
+ */
179
+ frameRate?: ConstrainDouble;
180
+
176
181
  /**
177
182
  * a facing or an array of facings which are acceptable and/or required.
178
183
  */
@@ -387,7 +392,7 @@ export interface AudioPreset {
387
392
 
388
393
  const backupCodecs = ['vp8', 'h264'] as const;
389
394
 
390
- export const videoCodecs = ['vp8', 'h264', 'vp9', 'av1'] as const;
395
+ export const videoCodecs = ['vp8', 'h264', 'vp9', 'av1', 'h265'] as const;
391
396
 
392
397
  export type VideoCodec = (typeof videoCodecs)[number];
393
398
 
@@ -1,4 +1,4 @@
1
- import { TrackPublishedResponse, TrackSource } from '@livekit/protocol';
1
+ import { TrackInfo, TrackPublishedResponse, TrackSource, VideoQuality } from '@livekit/protocol';
2
2
  import type { AudioProcessorOptions, TrackProcessor, VideoProcessorOptions } from '../..';
3
3
  import { cloneDeep } from '../../utils/cloneDeep';
4
4
  import { isSafari, sleep } from '../utils';
@@ -329,3 +329,14 @@ export function getTrackSourceFromProto(source: TrackSource): Track.Source {
329
329
  return Track.Source.Unknown;
330
330
  }
331
331
  }
332
+
333
+ export function areDimensionsSmaller(a: Track.Dimensions, b: Track.Dimensions): boolean {
334
+ return a.width * a.height < b.width * b.height;
335
+ }
336
+
337
+ export function layerDimensionsFor(
338
+ trackInfo: TrackInfo,
339
+ quality: VideoQuality,
340
+ ): Track.Dimensions | undefined {
341
+ return trackInfo.layers?.find((l) => l.quality === quality);
342
+ }
package/src/room/utils.ts CHANGED
@@ -96,6 +96,14 @@ export function supportsVP9(): boolean {
96
96
  // Safari 16 and below does not support VP9
97
97
  return false;
98
98
  }
99
+ if (
100
+ browser?.os === 'iOS' &&
101
+ browser?.osVersion &&
102
+ compareVersions(browser.osVersion, '16') < 0
103
+ ) {
104
+ // Safari 16 and below on iOS does not support VP9 we need the iOS check to account for other browsers running webkit under the hood
105
+ return false;
106
+ }
99
107
  }
100
108
  const capabilities = RTCRtpSender.getCapabilities('video');
101
109
  let hasVP9 = false;
@@ -110,6 +118,24 @@ export function supportsVP9(): boolean {
110
118
  return hasVP9;
111
119
  }
112
120
 
121
+ export function supportsH265(): boolean {
122
+ if (!('getCapabilities' in RTCRtpSender)) {
123
+ return false;
124
+ }
125
+
126
+ const capabilities = RTCRtpSender.getCapabilities('video');
127
+ let hasH265 = false;
128
+ if (capabilities) {
129
+ for (const codec of capabilities.codecs) {
130
+ if (codec.mimeType === 'video/H265') {
131
+ hasH265 = true;
132
+ break;
133
+ }
134
+ }
135
+ }
136
+ return hasH265;
137
+ }
138
+
113
139
  export function isSVCCodec(codec?: string): boolean {
114
140
  return codec === 'av1' || codec === 'vp9';
115
141
  }
@@ -148,9 +174,12 @@ export function isSafariBased(): boolean {
148
174
  return b?.name === 'Safari' || b?.os === 'iOS';
149
175
  }
150
176
 
151
- export function isSafari17(): boolean {
177
+ export function isSafari17Based(): boolean {
152
178
  const b = getBrowser();
153
- return b?.name === 'Safari' && b.version.startsWith('17.');
179
+ return (
180
+ (b?.name === 'Safari' && b.version.startsWith('17.')) ||
181
+ (b?.os === 'iOS' && !!b?.osVersion && compareVersions(b.osVersion, '17') >= 0)
182
+ );
154
183
  }
155
184
 
156
185
  export function isSafariSvcApi(browser?: BrowserDetails): boolean {
@@ -158,7 +187,12 @@ export function isSafariSvcApi(browser?: BrowserDetails): boolean {
158
187
  browser = getBrowser();
159
188
  }
160
189
  // Safari 18.4 requires legacy svc api and scaleResolutionDown to be set
161
- return browser?.name === 'Safari' && compareVersions(browser.version, '18.3') > 0;
190
+ return (
191
+ (browser?.name === 'Safari' && compareVersions(browser.version, '18.3') > 0) ||
192
+ (browser?.os === 'iOS' &&
193
+ !!browser?.osVersion &&
194
+ compareVersions(browser.osVersion, '18.3') > 0)
195
+ );
162
196
  }
163
197
 
164
198
  export function isMobile(): boolean {