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 (