Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/UrlParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export interface UrlConfiguration {
* Element Call.
*/
controlledAudioDevices: boolean;
audioInputOutputSelection: boolean;
/**
* Setting this flag skips the lobby and brings you in the call directly.
* In the widget this can be combined with preload to pass the device settings
Expand Down Expand Up @@ -372,6 +373,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
audioInputOutputSelection: platform !== "ios",
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
Expand Down Expand Up @@ -427,6 +429,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
audioInputOutputSelection: true,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
Expand Down
60 changes: 60 additions & 0 deletions src/button/AudioRouteButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import { useTranslation } from "react-i18next";
import { Button, Tooltip } from "@vector-im/compound-web";
import {
EarpieceIcon,
HeadphonesSolidIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";

import type { ComponentPropsWithoutRef, FC } from "react";
import { RouteType } from "../controls.ts";

interface AudioRouteButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "md" | "lg";
routeType: RouteType;
}

export const AudioRouteButton: FC<AudioRouteButtonProps> = ({
routeType,
...props
}) => {
const { t } = useTranslation();
let label: string
let icon;
switch(routeType) {
case RouteType.speaker:
label = t("settings.devices.loudspeaker")
icon = VolumeOnSolidIcon;
break;
case RouteType.phone:
label = t("settings.devices.handset");
icon = EarpieceIcon
break;
case RouteType.bluetooth:
label = "bluetooth headset";
icon = HeadphonesSolidIcon;
break;
case RouteType.wired:
label = "headset";
icon = HeadphonesSolidIcon;
break;
}

return (
<Tooltip label={label}>
<Button
iconOnly
Icon={icon}
{...props}
kind={"primary"}
/>
</Tooltip>
);
};
37 changes: 28 additions & 9 deletions src/components/CallFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type AudioRoute, RouteType } from "../controls.ts";

export interface AudioOutputSwitcher {
targetOutput: string;
Expand Down Expand Up @@ -90,6 +91,8 @@ export interface FooterProps {
selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void;

nativeAudioRoute?: { targetOutput: AudioRoute , switch: () => void };
}

export const CallFooter: FC<FooterProps> = ({
Expand All @@ -112,6 +115,7 @@ export const CallFooter: FC<FooterProps> = ({
reactionIdentifier,
reactionData,
audioOutputSwitcher,
nativeAudioRoute,
hangup,
debugTileLayout,
tileStoreGeneration,
Expand Down Expand Up @@ -228,15 +232,30 @@ export const CallFooter: FC<FooterProps> = ({

// In this PR we just move the button to the bottom bar. We do not yet update its appearance
const audioOutputButton = useMemo(() => {
if (audioOutputSwitcher === undefined) return null;
return (
<LoudspeakerButton
size={buttonSize}
onClick={() => audioOutputSwitcher.switch()}
loudspeakerModeEnabled={audioOutputSwitcher.targetOutput === "earpiece"}
/>
);
}, [audioOutputSwitcher, buttonSize]);

if (nativeAudioRoute) {
return (
// TODO make a 4 state button to also include the headset option when supported by the OS
<LoudspeakerButton
size={buttonSize}
onClick={() => nativeAudioRoute.switch()}
loudspeakerModeEnabled={nativeAudioRoute.targetOutput.type === RouteType.speaker}
/>
);
} else {
if (audioOutputSwitcher === undefined) return null;
return (
<LoudspeakerButton
size={buttonSize}
onClick={() => audioOutputSwitcher.switch()}
loudspeakerModeEnabled={
audioOutputSwitcher.targetOutput === "earpiece"
}
/>
);
}
}, [audioOutputSwitcher, buttonSize, nativeAudioRoute]);


if (audioOutputButton) buttons.push(audioOutputButton);

Expand Down
29 changes: 29 additions & 0 deletions src/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ Please see LICENSE in the repository root for full details.
import { Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";

export enum RouteType {
speaker = "speaker",
phone = "phone",
bluetooth = "bluetooth",
wired = "wired",
}

export interface AudioRoute {
type: RouteType;
label: string;
}

export interface Controls {
canEnterPip(): boolean;
enablePip(): void;
Expand All @@ -31,6 +43,9 @@ export interface Controls {
setOutputEnabled(enabled: boolean): void;
/** @deprecated use showNativeAudioDevicePicker instead*/
showNativeOutputDevicePicker?: () => void;

/** iOS native controlled device selection */
onNativeRouteChanged(route: AudioRoute): void;
}

/**
Expand Down Expand Up @@ -93,6 +108,8 @@ export const outputDevice$ = new Subject<string>();
*/
export const setAudioEnabled$ = new Subject<boolean>();

export const currentRoute$ = new Subject<AudioRoute | null>();

let playbackStartedEmitted = false;
export const setPlaybackStarted = (): void => {
if (!playbackStartedEmitted) {
Expand All @@ -101,6 +118,10 @@ export const setPlaybackStarted = (): void => {
}
};

export const showNativeAudioDevicePicker = (): void => {
window.controls.showNativeAudioDevicePicker?.();
};

window.controls = {
canEnterPip(): boolean {
return setPipEnabled$.observed;
Expand Down Expand Up @@ -156,6 +177,14 @@ window.controls = {
setAudioEnabled$.next(enabled);
},

onNativeRouteChanged(route: AudioRoute): void {
logger.info(
"[MediaDevices controls] onNativeRouteChanged called from native",
route,
);
currentRoute$.next(route);
},

// wrappers for the deprecated controls fields
setOutputEnabled(enabled: boolean): void {
this.setAudioEnabled(enabled);
Expand Down
2 changes: 1 addition & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ window.setLKLogLevel = setLKLogLevel;
initRageshake().catch((e) => {
logger.error("Failed to initialize rageshake", e);
});
setLKLogLevel("info");
setLKLogLevel("trace");
setLKLogExtension((level, msg, context) => {
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
global.mx_rage_logger.log(level, "livekit", msg, context);
Expand Down
68 changes: 67 additions & 1 deletion src/room/GroupCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,72 @@
};
}, []);

// Add this useEffect in GroupCallView.tsx to monitor native navigator.mediaDevices
useEffect(() => {
logger.info(
"[Navigator.mediaDevices Debug] Setting up native device monitoring",
);

// Log initial devices
navigator.mediaDevices
.enumerateDevices()

Check failure on line 151 in src/room/GroupCallView.tsx

View workflow job for this annotation

GitHub Actions / Run unit tests

src/room/GroupCallView.test.tsx > user can reconnect after a membership manager error

TypeError: Cannot read properties of undefined (reading 'enumerateDevices') ❯ src/room/GroupCallView.tsx:151:8 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25989:20 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ commitHookEffectListMount node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:13249:29 ❯ commitHookPassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:13336:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15484:13 ❯ recursivelyTraversePassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15718:11 ❯ recursivelyTraversePassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15476:11

Check failure on line 151 in src/room/GroupCallView.tsx

View workflow job for this annotation

GitHub Actions / Run unit tests

src/room/GroupCallView.test.tsx > Should not close widget when auto leave due to error

TypeError: Cannot read properties of undefined (reading 'enumerateDevices') ❯ src/room/GroupCallView.tsx:151:8 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25989:20 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ commitHookEffectListMount node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:13249:29 ❯ commitHookPassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:13336:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15484:13 ❯ recursivelyTraversePassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15718:11 ❯ recursivelyTraversePassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15476:11

Check failure on line 151 in src/room/GroupCallView.tsx

View workflow job for this annotation

GitHub Actions / Run unit tests

src/room/GroupCallView.test.tsx > Should close widget when all other left

TypeError: Cannot read properties of undefined (reading 'enumerateDevices') ❯ src/room/GroupCallView.tsx:151:8 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25989:20 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ commitHookEffectListMount node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:13249:29 ❯ commitHookPassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:13336:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15484:13 ❯ recursivelyTraversePassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15718:11 ❯ recursivelyTraversePassiveMountEffects node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:15476:11
.then((devices) => {
logger.info(
"[Navigator.mediaDevices Debug] Initial devices:",
devices.map((d) => ({
kind: d.kind,
deviceId: d.deviceId,
label: d.label,
groupId: d.groupId,
})),
);
})
.catch((e) =>
logger.error(
"[Navigator.mediaDevices Debug] Failed to enumerate initial devices:",
e,
),
);

// Monitor devicechange events
const handleDeviceChange = (): void => {
logger.info("[Navigator.mediaDevices Debug] 'devicechange' event fired!");

navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
logger.info(
"[Navigator.mediaDevices Debug] Devices after change:",
devices.map((d) => ({
kind: d.kind,
deviceId: d.deviceId,
label: d.label,
groupId: d.groupId,
})),
);
})
.catch((e) =>
logger.error(
"[Navigator.mediaDevices Debug] Failed to enumerate devices:",
e,
),
);
};

navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);

logger.info("[Navigator.mediaDevices Debug] Device monitoring active");

// Cleanup
return (): void => {
logger.info("[Navigator.mediaDevices Debug] Removing device monitoring");
navigator.mediaDevices.removeEventListener(
"devicechange",
handleDeviceChange,
);
};
}, []);

// This CSS is the only way we could find to not make element call scroll for
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
// tested on different devices...)
Expand Down Expand Up @@ -528,4 +594,4 @@
{body}
</GroupCallErrorBoundary>
);
};
};;
2 changes: 2 additions & 0 deletions src/room/InCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const nativeAudioRoute = useBehavior(vm.nativeAudioRouteSwitcher$);
const sharingScreen = useBehavior(vm.sharingScreen$);

const fatalCallError = useBehavior(vm.fatalError$);
Expand Down Expand Up @@ -590,6 +591,7 @@ export const InCallView: FC<InCallViewProps> = ({
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
reactionData={supportsReactions ? vm : undefined}
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
nativeAudioRoute = { nativeAudioRoute ?? undefined}
// Only pass the openSettings function if the settings button is not in the app bar.
// If there is no fn the button will be hidden in the footer.
openSettings={settingsButtonInAppBar ? undefined : openSettings}
Expand Down
43 changes: 43 additions & 0 deletions src/room/LobbyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "livekit-client";
import { useObservableEagerState } from "observable-hooks";
import { useNavigate } from "react-router-dom";
import { map, startWith } from "rxjs";

import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
Expand All @@ -48,6 +49,14 @@ import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import {
type AudioRoute,
currentRoute$,
RouteType,
showNativeAudioDevicePicker,
} from "../controls.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { startsWith } from "lodash-es";

interface Props {
client: MatrixClient;
Expand Down Expand Up @@ -184,6 +193,39 @@ export const LobbyView: FC<Props> = ({

useTrackProcessorSync(videoTrack);

const [nativeAudioRoute, setNativeAudioRoute] = useState<{
targetOutput: AudioRoute
switch: () => void
} | null>();
useEffect(() => {
const scope = new ObservableScope();
const nativeAudioRouteSwitcher$ = scope.behavior<{
targetOutput: AudioRoute;
switch: () => void;
} | null>(
currentRoute$.pipe(
map((route) => {
return {
targetOutput: route || { type: RouteType.speaker, label: "" },
switch: (): void => {
showNativeAudioDevicePicker?.();
},
};
}),
startWith(null),
),
);

nativeAudioRouteSwitcher$.subscribe((route) => {
setNativeAudioRoute(route);
});
return (): void => {
scope.end();
};
}, [setNativeAudioRoute]);



// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
return (
Expand Down Expand Up @@ -234,6 +276,7 @@ export const LobbyView: FC<Props> = ({
toggleVideo={toggleVideo ?? undefined}
openSettings={openSettings}
hangup={!confineToRoom ? onLeaveClick : undefined}
nativeAudioRoute={nativeAudioRoute ?? undefined}
// Logo and header are connected. We will only show the logo in SPA with header.
hideLogo={hideHeader}
>
Expand Down
Loading
Loading