From dc5203a8fe897e2c998b0806a75496b2902cf4bc Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 17 Sep 2025 19:12:14 +0200 Subject: [PATCH] default mute states (unmuted!) in widget mode (embedded + intent) Signed-off-by: Timo K --- src/UrlParams.ts | 58 ++++++++++++++++++------------ src/room/MuteStates.test.tsx | 69 ++++++++++++++++++++++++++++++++++-- src/room/MuteStates.ts | 22 +++++++++--- 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/src/UrlParams.ts b/src/UrlParams.ts index f5369765..c99d5c9d 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -228,13 +228,20 @@ export interface UrlConfiguration { */ waitForCallPickup: boolean; } +interface IntentDerivedConfiguration { + defaultAudioEnabled?: boolean; + defaultVideoEnabled?: boolean; +} // If you need to add a new flag to this interface, prefer a name that describes // a specific behavior (such as 'confineToRoom'), rather than one that describes // the situations that call for this behavior ('isEmbedded'). This makes it // clearer what each flag means, and helps us avoid coupling Element Call's // behavior to the needs of specific consumers. -export interface UrlParams extends UrlProperties, UrlConfiguration {} +export interface UrlParams + extends UrlProperties, + UrlConfiguration, + IntentDerivedConfiguration {} // This is here as a stopgap, but what would be far nicer is a function that // takes a UrlParams and returns a query string. That would enable us to @@ -343,8 +350,7 @@ export const getUrlParams = ( ? UserIntent.Unknown : (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown); // Here we only use constants and `platform` to determine the intent preset. - let intentPreset: UrlConfiguration; - const inAppDefault = { + let intentPreset: UrlConfiguration = { confineToRoom: true, appPrompt: false, preload: false, @@ -362,31 +368,22 @@ export const getUrlParams = ( }; switch (intent) { case UserIntent.StartNewCall: - intentPreset = { - ...inAppDefault, - skipLobby: true, - }; + intentPreset.skipLobby = true; break; case UserIntent.JoinExistingCall: - intentPreset = { - ...inAppDefault, - skipLobby: false, - }; + // On desktop this will be overridden based on which button was used to join the call + intentPreset.skipLobby = false; break; case UserIntent.StartNewCallDM: - intentPreset = { - ...inAppDefault, - skipLobby: true, - autoLeaveWhenOthersLeft: true, - waitForCallPickup: true, - }; + intentPreset.skipLobby = true; + intentPreset.autoLeaveWhenOthersLeft = true; + intentPreset.waitForCallPickup = true; + break; case UserIntent.JoinExistingCallDM: - intentPreset = { - ...inAppDefault, - skipLobby: true, - autoLeaveWhenOthersLeft: true, - }; + // On desktop this will be overridden based on which button was used to join the call + intentPreset.skipLobby = true; + intentPreset.autoLeaveWhenOthersLeft = true; break; // Non widget usecase defaults default: @@ -408,6 +405,22 @@ export const getUrlParams = ( }; } + const intentDerivedConfiguration: IntentDerivedConfiguration = {}; + const desktopMobile = platform === "desktop" ? "desktop" : "mobile"; + switch (desktopMobile) { + case "desktop": + case "mobile": + switch (intent) { + case UserIntent.StartNewCall: + case UserIntent.JoinExistingCall: + case UserIntent.StartNewCallDM: + case UserIntent.JoinExistingCallDM: + intentDerivedConfiguration.defaultAudioEnabled = true; + intentDerivedConfiguration.defaultVideoEnabled = true; + break; + } + } + const properties: UrlProperties = { widgetId, parentUrl, @@ -464,6 +477,7 @@ export const getUrlParams = ( ...properties, ...intentPreset, ...pickBy(configuration, (v?: unknown) => v !== undefined), + ...intentDerivedConfiguration, }; }; diff --git a/src/room/MuteStates.test.tsx b/src/room/MuteStates.test.tsx index d349a5c6..7a134b5e 100644 --- a/src/room/MuteStates.test.tsx +++ b/src/room/MuteStates.test.tsx @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { afterAll, afterEach, + beforeEach, describe, expect, it, @@ -26,7 +27,6 @@ import { MediaDevicesContext } from "../MediaDevicesContext"; import { mockConfig } from "../utils/test"; import { MediaDevices } from "../state/MediaDevices"; import { ObservableScope } from "../state/ObservableScope"; - vi.mock("@livekit/components-core"); interface TestComponentProps { @@ -110,9 +110,10 @@ function mockMediaDevices( return new MediaDevices(scope); } -describe("useMuteStates", () => { +describe("useMuteStates in full mode", () => { afterEach(() => { vi.clearAllMocks(); + vi.stubEnv("VITE_PACKAGE", "full"); }); afterAll(() => { @@ -256,3 +257,67 @@ describe("useMuteStates", () => { expect(screen.getByTestId("video-enabled").textContent).toBe("true"); }); }); + +describe("useMuteStates in embedded mode", () => { + beforeEach(() => { + vi.stubEnv("VITE_PACKAGE", "embedded"); + }); + + it("uses defaults from config", () => { + mockConfig({ + media_devices: { + enable_audio: false, + enable_video: false, + }, + }); + + render( + + + + + , + ); + expect(screen.getByTestId("audio-enabled").textContent).toBe("false"); + expect(screen.getByTestId("video-enabled").textContent).toBe("false"); + }); + + it("skipLobby does not mute inputs", () => { + mockConfig(); + + render( + + + + + , + ); + expect(screen.getByTestId("audio-enabled").textContent).toBe("true"); + expect(screen.getByTestId("video-enabled").textContent).toBe("true"); + }); + + it("url params win over config", () => { + // The config sets audio and video to disabled + mockConfig({ media_devices: { enable_audio: false, enable_video: false } }); + + render( + + + + + , + ); + // At the end we expect the url param to take precedence, resulting in true + expect(screen.getByTestId("audio-enabled").textContent).toBe("true"); + expect(screen.getByTestId("video-enabled").textContent).toBe("true"); + }); +}); diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts index 1f4b8e82..e89d13d9 100644 --- a/src/room/MuteStates.ts +++ b/src/room/MuteStates.ts @@ -81,11 +81,15 @@ function useMuteState( export function useMuteStates(isJoined: boolean): MuteStates { const devices = useMediaDevices(); - const { skipLobby } = useUrlParams(); + const { skipLobby, defaultAudioEnabled, defaultVideoEnabled } = + useUrlParams(); - const audio = useMuteState(devices.audioInput, () => { - return Config.get().media_devices.enable_audio && !skipLobby && !isJoined; - }); + const audio = useMuteState( + devices.audioInput, + () => + (defaultAudioEnabled ?? Config.get().media_devices.enable_audio) && + allowJoinUnmuted(skipLobby, isJoined), + ); useEffect(() => { // If audio is enabled, we need to request the device names again, // 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 { const isEarpiece = useIsEarpiece(); const video = useMuteState( devices.videoInput, - () => Config.get().media_devices.enable_video && !skipLobby && !isJoined, + () => + (defaultVideoEnabled ?? Config.get().media_devices.enable_video) && + allowJoinUnmuted(skipLobby, isJoined), isEarpiece, // Force video to be unavailable if using earpiece ); @@ -164,3 +170,9 @@ export function useMuteStates(isJoined: boolean): MuteStates { return useMemo(() => ({ audio, video }), [audio, video]); } + +function allowJoinUnmuted(skipLobby: boolean, isJoined: boolean): boolean { + return ( + (!skipLobby && !isJoined) || import.meta.env.VITE_PACKAGE === "embedded" + ); +}