From ba76fe895e813f8a24484680735c88c2583e4c2d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 18 Sep 2026 10:48:02 -0400 Subject: [PATCH] fix: wait for the first video frame on iPadOS in waitForDimensions waitForDimensions gated its first-frame wait on getBrowser()?.os === 'iOS'. browserParser decides iOS versus macOS for Safari by whether the user agent contains `mobile/`, and iPadOS has sent the desktop Macintosh user agent by default since iPadOS 13, so iPads took the old path and read getSettings() immediately. The same device flipped between the two paths depending on the per-site "Request Mobile Website" setting. Measured on an iPad 6 running iPadOS 17, front camera, held in portrait: the track reports 1280x960 for ~600ms and then corrects to 960x1280 at the first painted frame, in 5 of 5 captures. That is the window waitForDimensions was reading inside, so a portrait iPad announced itself to the room as landscape. Adds isIPadOS()/isAppleMobile() and gates on the latter. isIPadOS() keys on touch capability, which separates the two platforms cleanly: macOS Safari implements no touch events at all (TouchEvent undefined, maxTouchPoints 0 regardless of any attached touchscreen) while the iPad reports maxTouchPoints 5. It is scoped to Safari on a Macintosh user agent, so no other browser reaches the check. Deliberately a separate helper rather than a fix inside browserParser: iPadOS spoofs the Mac version too, so reporting os: 'iOS' there would leave osVersion at '10.15.7' and silently break isSafari17Based() and isSafariSvcApi(), which compare it. The gate stays narrow rather than running everywhere. On macOS Safari the first frame takes 487-826ms (measured over 10 captures) and getSettings() never changes, so waiting there would be pure added publish latency on the critical path for a bug that platform does not have. Fixes #2107 --- .changeset/lazy-pumas-repeat.md | 5 +++ src/room/track/LocalTrack.ts | 14 ++++--- src/room/utils.test.ts | 74 +++++++++++++++++++++++++++++++++ src/room/utils.ts | 27 ++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 .changeset/lazy-pumas-repeat.md diff --git a/.changeset/lazy-pumas-repeat.md b/.changeset/lazy-pumas-repeat.md new file mode 100644 index 0000000000..cfad7d6b2e --- /dev/null +++ b/.changeset/lazy-pumas-repeat.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Wait for the first video frame in waitForDimensions on iPadOS, which reports a desktop user agent diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index de17a58e31..d051b1ec6f 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -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'; @@ -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); } diff --git a/src/room/utils.test.ts b/src/room/utils.test.ts index a6b3541df0..e515ce8e39 100644 --- a/src/room/utils.test.ts +++ b/src/room/utils.test.ts @@ -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, @@ -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); + }); +}); diff --git a/src/room/utils.ts b/src/room/utils.ts index f3f21a0987..48970ec567 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -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(); +} + export function isSafari17Based(): boolean { const b = getBrowser(); return (