diff --git a/src/room/utils.test.ts b/src/room/utils.test.ts index ebddf911d9..695f94fafb 100644 --- a/src/room/utils.test.ts +++ b/src/room/utils.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { splitUtf8, toWebsocketUrl } from './utils'; +import { isSafariSpeakerSelectionSupported, supportsSetSinkId } from './utils'; + describe('toWebsocketUrl', () => { it('leaves wss urls alone', () => { @@ -60,3 +62,74 @@ describe('splitUtf8', () => { expect(splitUtf8('', 5)).toEqual([]); }); }); + +describe('isSafariSpeakerSelectionSupported', () => { + it('returns true for Safari >= 26', () => { + expect(isSafariSpeakerSelectionSupported({ + name: 'Safari', + version: '26.0', + os: 'macOS', + osVersion: '26.0', + })).toBe(true); + expect(isSafariSpeakerSelectionSupported({ + name: 'Safari', + version: '27.1', + os: 'macOS', + osVersion: '27.1', + })).toBe(true); + }); + + it('returns true for iOS Safari >= 26', () => { + expect(isSafariSpeakerSelectionSupported({ + name: 'Safari', + version: '26.0', + os: 'iOS', + osVersion: '26.0', + })).toBe(true); + }); + + it('returns false for Safari < 26', () => { + expect(isSafariSpeakerSelectionSupported({ + name: 'Safari', + version: '25.9', + os: 'macOS', + osVersion: '25.9', + })).toBe(false); + }); + + it('returns false for non-Safari browsers', () => { + expect(isSafariSpeakerSelectionSupported({ + name: 'Chrome', + version: '120.0', + os: 'macOS', + osVersion: '14.0', + })).toBe(false); + }); +}); + +describe('supportsSetSinkId', () => { + it('returns true if setSinkId is present', () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0', + configurable: true, + }); + const fakeAudio = { setSinkId: () => {} } as any as HTMLMediaElement; + expect(supportsSetSinkId(fakeAudio)).toBe(true); + }); + it('returns true if setSinkId is present', () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15', + configurable: true, + }); + const fakeAudio = { setSinkId: () => {} } as any as HTMLMediaElement; + expect(supportsSetSinkId(fakeAudio)).toBe(true); + }); + it('returns false if setSinkId not supported', () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15', + configurable: true, + }); + const fakeAudio = { setSinkId: () => {} } as any as HTMLMediaElement; + expect(supportsSetSinkId(fakeAudio)).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/room/utils.ts b/src/room/utils.ts index d9ff0c574e..e7aaaa011f 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -142,8 +142,8 @@ export function isSVCCodec(codec?: string): boolean { } export function supportsSetSinkId(elm?: HTMLMediaElement): boolean { - if (!document || isSafariBased()) { - return false; + if (!document || (!isSafariSpeakerSelectionSupported() && isSafariBased())) { + return false } if (!elm) { elm = document.createElement('audio'); @@ -196,6 +196,19 @@ export function isSafariSvcApi(browser?: BrowserDetails): boolean { ); } +export function isSafariSpeakerSelectionSupported(browser?: BrowserDetails): boolean { + if (!browser) { + browser = getBrowser(); + } + // Safari (macOS or iOS) since version 26 + // https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes#WebRTC + return ( + (browser?.name === 'Safari' && compareVersions(browser.version, '26') >= 0) || + (browser?.os === 'iOS' && !!browser?.osVersion && compareVersions(browser.osVersion, '26') >= 0) || + (browser?.os === 'macOS' && !!browser?.osVersion && compareVersions(browser.osVersion, '26') >= 0) + ); +} + export function isMobile(): boolean { if (!isWeb()) return false;