Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-pumas-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-client': patch
---

Wait for the first video frame in waitForDimensions on iPadOS, which reports a desktop user agent
14 changes: 9 additions & 5 deletions src/room/track/LocalTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { debounce } from '../debounce';
import { DeviceUnsupportedError, TrackInvalidError } from '../errors';
import { TrackEvent } from '../events';
import type { LoggerOptions } from '../types';
import { compareVersions, isMobile, sleep, unwrapConstraint } from '../utils';
import { compareVersions, isAppleMobile, isMobile, sleep, unwrapConstraint } from '../utils';
import { Track, attachToElement, detachTrack } from './Track';
import type { VideoCodec } from './options';
import type { TrackProcessor } from './processor/types';
Expand Down Expand Up @@ -227,11 +227,15 @@ export default abstract class LocalTrack<

const started = Date.now();

if (getBrowser()?.os === 'iOS') {
// iOS keeps reporting the camera's sensor frame from getSettings() until the camera has
// delivered its first picture, so a portrait capture initially reads as landscape.
// Wait for that first frame instead of guessing at a delay.
if (isAppleMobile()) {
// iOS and iPadOS keep reporting the camera's sensor frame from getSettings() until the
// camera has delivered its first picture, so a portrait capture initially reads as
// landscape. Wait for that first frame instead of guessing at a delay.
// Gated to the Apple capture pipeline rather than run everywhere: elsewhere getSettings() is
// already right and the first frame can be hundreds of milliseconds out (measured ~500ms on
// macOS Safari), which would be pure added publish latency.
// https://github.com/livekit/client-sdk-js/issues/2099
// https://github.com/livekit/client-sdk-js/issues/2107
await waitForFirstVideoFrame(this._mediaStreamTrack, timeout, this.attachedElements);
}

Expand Down
74 changes: 74 additions & 0 deletions src/room/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { ClientInfo_Capability } from '@livekit/protocol';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getBrowser } from '../utils/browserParser';
import {
ddExtensionURI,
extractMaxAgeFromRequestHeaders,
getClientInfo,
isAppleMobile,
isIPadOS,
isSVCSimulcast,
isSVCSimulcastSupportedByServer,
negotiateDependencyDescriptor,
Expand Down Expand Up @@ -367,3 +370,74 @@ describe('usesLegacySVCEncodings', () => {
expect(usesLegacySVCEncodings()).toBe(true);
});
});

describe('isIPadOS / isAppleMobile', () => {
const stub = (userAgent: string, maxTouchPoints: number) =>
vi.stubGlobal('navigator', { userAgent, maxTouchPoints, product: 'Gecko' });

afterEach(() => {
vi.unstubAllGlobals();
});

// measured on an iPad 6 running iPadOS 17: the default (desktop) user agent, maxTouchPoints 5
const IPAD_DESKTOP_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.14 Safari/605.1.15';
// measured on macOS Safari 26.4: the same shape of user agent, maxTouchPoints 0
const MAC_SAFARI_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15';
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Mobile/15E148 Safari/604.1';
const IPAD_MOBILE_UA =
'Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
const MAC_CHROME_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
const ANDROID_CHROME_UA =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36';

it('detects an iPad sending the desktop user agent', () => {
stub(IPAD_DESKTOP_UA, 5);
expect(isIPadOS()).toBe(true);
expect(isAppleMobile()).toBe(true);
});

it('is the case getBrowser() alone gets wrong', () => {
// the gap this exists to close: iPadOS parses as macOS, so `os === 'iOS'` misses it
stub(IPAD_DESKTOP_UA, 5);
expect(getBrowser()?.os).toBe('macOS');
});

it('leaves macOS Safari alone, where the camera reports correctly and frames are slow', () => {
stub(MAC_SAFARI_UA, 0);
expect(isIPadOS()).toBe(false);
expect(isAppleMobile()).toBe(false);
});

it('covers iPhones through the existing iOS check', () => {
stub(IPHONE_UA, 5);
expect(isIPadOS()).toBe(false);
expect(isAppleMobile()).toBe(true);
});

it('covers an iPad set to request the mobile site', () => {
stub(IPAD_MOBILE_UA, 5);
expect(isIPadOS()).toBe(false);
expect(isAppleMobile()).toBe(true);
});

it('does not fire for a Mac with a touchscreen attached, which is not Safari', () => {
stub(MAC_CHROME_UA, 5);
expect(isIPadOS()).toBe(false);
expect(isAppleMobile()).toBe(false);
});

it('does not fire on android', () => {
stub(ANDROID_CHROME_UA, 5);
expect(isIPadOS()).toBe(false);
expect(isAppleMobile()).toBe(false);
});

it('does not fire on a desktop safari reporting no touch support', () => {
stub(MAC_SAFARI_UA, 1);
expect(isIPadOS()).toBe(false);
});
});
27 changes: 27 additions & 0 deletions src/room/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,33 @@ export function isSafariBased(): boolean {
return b?.name === 'Safari' || b?.os === 'iOS';
}

/**
* iPadOS has sent the desktop Macintosh user agent by default since iPadOS 13, so `getBrowser()`
* reports `os: 'macOS'` there and every `os === 'iOS'` check misses it. macOS Safari implements no
* touch events at all (`TouchEvent` undefined, `maxTouchPoints` 0) regardless of any touchscreen
* attached, so touch capability separates the two. Measured on an iPad 6 / iPadOS 17
* (`maxTouchPoints` 5) against macOS Safari 26.4 (`maxTouchPoints` 0).
*
* Deliberately a separate helper rather than a fix in `browserParser`: iPadOS spoofs the Mac
* version too, so reporting `os: 'iOS'` there would leave `osVersion` at `10.15.7` and silently
* break {@link isSafari17Based} and {@link isSafariSvcApi}, which compare it.
*/
export function isIPadOS(): boolean {
if (!isWeb()) {
return false;
}
const b = getBrowser();
return b?.name === 'Safari' && b?.os === 'macOS' && navigator.maxTouchPoints > 1;
}

/**
* iPhone, iPad and iOS-hosted browsers — everything running the Apple camera capture pipeline,
* including iPads that present themselves as a Mac.
*/
export function isAppleMobile(): boolean {
return getBrowser()?.os === 'iOS' || isIPadOS();
}

Comment on lines +303 to +329

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the highest risk part of this change.

As a datapoint, I have an older ipad 6 and iphone 11 kicking around along with my mac - I ran the above functions on all three platforms and isIPadOS() returned true only for the ipad, and isAppleMobile() returned true for both the ipad and iphone.

Related issue: #1788. Not 100% sure this handles all cases right though and would like another perspective before merging.

export function isSafari17Based(): boolean {
const b = getBrowser();
return (
Expand Down
Loading