Skip to content

Commit db5c7cf

Browse files
authored
default mute states (unmuted!) in widget mode (embedded + intent) (#3494)
* default mute states (unmuted!) in widget mode (embedded + intent) Signed-off-by: Timo K <[email protected]> * review Signed-off-by: Timo K <[email protected]> * introduce a cache for the url params. Signed-off-by: Timo K <[email protected]> * Add an option to skip the cache. Signed-off-by: Timo K <[email protected]> --------- Signed-off-by: Timo K <[email protected]>
1 parent 63122c7 commit db5c7cf

File tree

3 files changed

+156
-30
lines changed

3 files changed

+156
-30
lines changed

src/UrlParams.ts

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -228,13 +228,20 @@ export interface UrlConfiguration {
228228
*/
229229
waitForCallPickup: boolean;
230230
}
231+
interface IntentAndPlatformDerivedConfiguration {
232+
defaultAudioEnabled?: boolean;
233+
defaultVideoEnabled?: boolean;
234+
}
231235

232236
// If you need to add a new flag to this interface, prefer a name that describes
233237
// a specific behavior (such as 'confineToRoom'), rather than one that describes
234238
// the situations that call for this behavior ('isEmbedded'). This makes it
235239
// clearer what each flag means, and helps us avoid coupling Element Call's
236240
// behavior to the needs of specific consumers.
237-
export interface UrlParams extends UrlProperties, UrlConfiguration {}
241+
export interface UrlParams
242+
extends UrlProperties,
243+
UrlConfiguration,
244+
IntentAndPlatformDerivedConfiguration {}
238245

239246
// This is here as a stopgap, but what would be far nicer is a function that
240247
// takes a UrlParams and returns a query string. That would enable us to
@@ -310,6 +317,11 @@ class ParamParser {
310317
}
311318
}
312319

320+
let urlParamCache: {
321+
search?: string;
322+
hash?: string;
323+
params?: UrlParams;
324+
} = {};
313325
/**
314326
* Gets the app parameters for the current URL.
315327
* @param search The URL search string
@@ -319,7 +331,18 @@ class ParamParser {
319331
export const getUrlParams = (
320332
search = window.location.search,
321333
hash = window.location.hash,
334+
/** Skipping the cache might be needed in tests, to allow recomputing based on mocked platform changes. */
335+
skipCache = false,
322336
): UrlParams => {
337+
// Only run the param configuration if we do not yet have it cached for this url.
338+
if (
339+
urlParamCache.search === search &&
340+
urlParamCache.hash === hash &&
341+
urlParamCache.params &&
342+
!skipCache
343+
) {
344+
return urlParamCache.params;
345+
}
323346
const parser = new ParamParser(search, hash);
324347

325348
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
@@ -343,8 +366,7 @@ export const getUrlParams = (
343366
? UserIntent.Unknown
344367
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
345368
// Here we only use constants and `platform` to determine the intent preset.
346-
let intentPreset: UrlConfiguration;
347-
const inAppDefault = {
369+
let intentPreset: UrlConfiguration = {
348370
confineToRoom: true,
349371
appPrompt: false,
350372
preload: false,
@@ -362,31 +384,22 @@ export const getUrlParams = (
362384
};
363385
switch (intent) {
364386
case UserIntent.StartNewCall:
365-
intentPreset = {
366-
...inAppDefault,
367-
skipLobby: true,
368-
};
387+
intentPreset.skipLobby = true;
369388
break;
370389
case UserIntent.JoinExistingCall:
371-
intentPreset = {
372-
...inAppDefault,
373-
skipLobby: false,
374-
};
390+
// On desktop this will be overridden based on which button was used to join the call
391+
intentPreset.skipLobby = false;
375392
break;
376393
case UserIntent.StartNewCallDM:
377-
intentPreset = {
378-
...inAppDefault,
379-
skipLobby: true,
380-
autoLeaveWhenOthersLeft: true,
381-
waitForCallPickup: true,
382-
};
394+
intentPreset.skipLobby = true;
395+
intentPreset.autoLeaveWhenOthersLeft = true;
396+
intentPreset.waitForCallPickup = true;
397+
383398
break;
384399
case UserIntent.JoinExistingCallDM:
385-
intentPreset = {
386-
...inAppDefault,
387-
skipLobby: true,
388-
autoLeaveWhenOthersLeft: true,
389-
};
400+
// On desktop this will be overridden based on which button was used to join the call
401+
intentPreset.skipLobby = true;
402+
intentPreset.autoLeaveWhenOthersLeft = true;
390403
break;
391404
// Non widget usecase defaults
392405
default:
@@ -408,6 +421,24 @@ export const getUrlParams = (
408421
};
409422
}
410423

424+
const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
425+
{};
426+
// Desktop also includes web. Its anything that is not mobile.
427+
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
428+
switch (desktopMobile) {
429+
case "desktop":
430+
case "mobile":
431+
switch (intent) {
432+
case UserIntent.StartNewCall:
433+
case UserIntent.JoinExistingCall:
434+
case UserIntent.StartNewCallDM:
435+
case UserIntent.JoinExistingCallDM:
436+
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
437+
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
438+
break;
439+
}
440+
}
441+
411442
const properties: UrlProperties = {
412443
widgetId,
413444
parentUrl,
@@ -460,11 +491,29 @@ export const getUrlParams = (
460491
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
461492
};
462493

463-
return {
494+
// Log the final configuration for debugging purposes.
495+
// This will only log when the cache is not yet set.
496+
logger.info(
497+
"UrlParams: final set of url params\n",
498+
"intent:",
499+
intent,
500+
"\nproperties:",
501+
properties,
502+
"configuration:",
503+
configuration,
504+
"intentAndPlatformDerivedConfiguration:",
505+
intentAndPlatformDerivedConfiguration,
506+
);
507+
508+
const params = {
464509
...properties,
465510
...intentPreset,
466511
...pickBy(configuration, (v?: unknown) => v !== undefined),
512+
...intentAndPlatformDerivedConfiguration,
467513
};
514+
urlParamCache = { search, hash, params };
515+
516+
return params;
468517
};
469518

470519
/**

src/room/MuteStates.test.tsx

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
88
import {
99
afterAll,
1010
afterEach,
11+
beforeEach,
1112
describe,
1213
expect,
1314
it,
@@ -26,7 +27,6 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
2627
import { mockConfig } from "../utils/test";
2728
import { MediaDevices } from "../state/MediaDevices";
2829
import { ObservableScope } from "../state/ObservableScope";
29-
3030
vi.mock("@livekit/components-core");
3131

3232
interface TestComponentProps {
@@ -110,9 +110,10 @@ function mockMediaDevices(
110110
return new MediaDevices(scope);
111111
}
112112

113-
describe("useMuteStates", () => {
113+
describe("useMuteStates VITE_PACKAGE='full' (SPA) mode", () => {
114114
afterEach(() => {
115115
vi.clearAllMocks();
116+
vi.stubEnv("VITE_PACKAGE", "full");
116117
});
117118

118119
afterAll(() => {
@@ -256,3 +257,67 @@ describe("useMuteStates", () => {
256257
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
257258
});
258259
});
260+
261+
describe("useMuteStates in VITE_PACKAGE='embedded' (widget) mode", () => {
262+
beforeEach(() => {
263+
vi.stubEnv("VITE_PACKAGE", "embedded");
264+
});
265+
266+
it("uses defaults from config", () => {
267+
mockConfig({
268+
media_devices: {
269+
enable_audio: false,
270+
enable_video: false,
271+
},
272+
});
273+
274+
render(
275+
<MemoryRouter>
276+
<MediaDevicesContext value={mockMediaDevices()}>
277+
<TestComponent />
278+
</MediaDevicesContext>
279+
</MemoryRouter>,
280+
);
281+
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
282+
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
283+
});
284+
285+
it("skipLobby does not mute inputs", () => {
286+
mockConfig();
287+
288+
render(
289+
<MemoryRouter
290+
initialEntries={[
291+
"/room/?skipLobby=true&widgetId=1234&parentUrl=www.parent.org",
292+
]}
293+
>
294+
<MediaDevicesContext value={mockMediaDevices()}>
295+
<TestComponent />
296+
</MediaDevicesContext>
297+
</MemoryRouter>,
298+
);
299+
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
300+
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
301+
});
302+
303+
it("url params win over config", () => {
304+
// The config sets audio and video to disabled
305+
mockConfig({ media_devices: { enable_audio: false, enable_video: false } });
306+
307+
render(
308+
<MemoryRouter
309+
initialEntries={[
310+
// The Intent sets both audio and video enabled to true via the url param configuration
311+
"/room/?intent=start_call_dm&widgetId=1234&parentUrl=www.parent.org",
312+
]}
313+
>
314+
<MediaDevicesContext value={mockMediaDevices()}>
315+
<TestComponent />
316+
</MediaDevicesContext>
317+
</MemoryRouter>,
318+
);
319+
// At the end we expect the url param to take precedence, resulting in true
320+
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
321+
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
322+
});
323+
});

src/room/MuteStates.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,15 @@ function useMuteState(
8181
export function useMuteStates(isJoined: boolean): MuteStates {
8282
const devices = useMediaDevices();
8383

84-
const { skipLobby } = useUrlParams();
84+
const { skipLobby, defaultAudioEnabled, defaultVideoEnabled } =
85+
useUrlParams();
8586

86-
const audio = useMuteState(devices.audioInput, () => {
87-
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
88-
});
87+
const audio = useMuteState(
88+
devices.audioInput,
89+
() =>
90+
(defaultAudioEnabled ?? Config.get().media_devices.enable_audio) &&
91+
allowJoinUnmuted(skipLobby, isJoined),
92+
);
8993
useEffect(() => {
9094
// If audio is enabled, we need to request the device names again,
9195
// because iOS will not be able to switch to the correct device after un-muting.
@@ -97,7 +101,9 @@ export function useMuteStates(isJoined: boolean): MuteStates {
97101
const isEarpiece = useIsEarpiece();
98102
const video = useMuteState(
99103
devices.videoInput,
100-
() => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
104+
() =>
105+
(defaultVideoEnabled ?? Config.get().media_devices.enable_video) &&
106+
allowJoinUnmuted(skipLobby, isJoined),
101107
isEarpiece, // Force video to be unavailable if using earpiece
102108
);
103109

@@ -164,3 +170,9 @@ export function useMuteStates(isJoined: boolean): MuteStates {
164170

165171
return useMemo(() => ({ audio, video }), [audio, video]);
166172
}
173+
174+
function allowJoinUnmuted(skipLobby: boolean, isJoined: boolean): boolean {
175+
return (
176+
(!skipLobby && !isJoined) || import.meta.env.VITE_PACKAGE === "embedded"
177+
);
178+
}

0 commit comments

Comments
 (0)