WIP on audio route experiment

This commit is contained in:
Valere
2026-05-15 16:53:49 +02:00
parent f4ff790d2c
commit 62bc420df9
12 changed files with 288 additions and 25 deletions

View File

@@ -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
@@ -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",
@@ -427,6 +429,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
audioInputOutputSelection: true,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,

View File

@@ -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>
);
};

View File

@@ -34,6 +34,7 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type AudioRoute, RouteType } from "../controls.ts";
export interface AudioOutputSwitcher {
targetOutput: string;
@@ -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> = ({
@@ -112,6 +115,7 @@ export const CallFooter: FC<FooterProps> = ({
reactionIdentifier,
reactionData,
audioOutputSwitcher,
nativeAudioRoute,
hangup,
debugTileLayout,
tileStoreGeneration,
@@ -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);

View File

@@ -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;
@@ -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;
}
/**
@@ -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) {
@@ -101,6 +118,10 @@ export const setPlaybackStarted = (): void => {
}
};
export const showNativeAudioDevicePicker = (): void => {
window.controls.showNativeAudioDevicePicker?.();
};
window.controls = {
canEnterPip(): boolean {
return setPipEnabled$.observed;
@@ -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);

View File

@@ -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);

View File

@@ -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$);
@@ -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}

View File

@@ -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";
@@ -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;
@@ -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 (
@@ -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}
>

View File

@@ -41,9 +41,9 @@ import {
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
MembershipManagerEvent,
type LivekitTransportConfig,
type MatrixRTCSession,
MembershipManagerEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -64,7 +64,14 @@ import {
showReactions,
} from "../../settings/settings";
import { isFirefox, platform } from "../../Platform";
import { setPipEnabled$ } from "../../controls";
import {
type AudioRoute,
currentRoute$,
RouteType,
setPipEnabled$,
showNativeAudioDevicePicker,
showNativeAudioDevicePicker$,
} from "../../controls";
import { TileStore } from "../TileStore";
import { gridLikeLayout } from "../GridLikeLayout";
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
@@ -78,7 +85,7 @@ import {
} from "../../reactions";
import { shallowEquals } from "../../utils/array";
import { type MediaDevices } from "../MediaDevices";
import { constant, type Behavior } from "../Behavior";
import { type Behavior, constant } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates";
@@ -122,8 +129,8 @@ import {
import {
createMatrixLivekitMembers$,
type LocalMatrixLivekitMember,
type RemoteMatrixLivekitMember,
type MatrixLivekitMember,
type RemoteMatrixLivekitMember,
} from "./remoteMembers/MatrixLivekitMembers.ts";
import {
type AutoLeaveReason,
@@ -368,6 +375,14 @@ export interface CallViewModel {
switch: () => void;
} | null>;
/**
* Use when in native controlled route mode
*/
nativeAudioRouteSwitcher$: Behavior<{
targetOutput: AudioRoute;
switch: () => void;
} | null>;
/**
* Whether the app is currently reconnecting to the LiveKit server and/or setting the matrix rtc room state.
*/
@@ -1435,6 +1450,23 @@ export function createCallViewModel$(
),
);
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),
),
);
/**
* Emits an array of reactions that should be visible on the screen.
*/
@@ -1624,6 +1656,7 @@ export function createCallViewModel$(
showFooter$: showFooter$,
earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$,
nativeAudioRouteSwitcher$: nativeAudioRouteSwitcher$,
reconnecting$: localMembership.reconnecting$,
livekitRoomItems$,
connected$: localMembership.connected$,

View File

@@ -64,7 +64,8 @@ export class Publisher {
trackerProcessorState$: Behavior<ProcessorState>,
private logger: Logger,
) {
const { controlledAudioDevices } = getUrlParams();
const { controlledAudioDevices, audioInputOutputSelection } =
getUrlParams();
const room = connection.livekitRoom;
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
@@ -74,7 +75,12 @@ export class Publisher {
// Setup track processor syncing (blur)
this.observeTrackProcessors(this.scope, room, trackerProcessorState$);
// Observe media device changes and update LiveKit active devices accordingly
this.observeMediaDevices(this.scope, devices, controlledAudioDevices);
this.observeMediaDevices(
this.scope,
devices,
controlledAudioDevices,
audioInputOutputSelection,
);
this.workaroundRestartAudioInputTrackChrome(devices, this.scope);
@@ -304,7 +310,7 @@ export class Publisher {
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended"
) {
this.logger?.info(
this.logger.info(
"Restarting audio device track due to active media device changed (workaroundRestartAudioInputTrackChrome)",
);
// Restart the track, which will cause Livekit to do another
@@ -326,14 +332,18 @@ export class Publisher {
scope: ObservableScope,
devices: MediaDevices,
controlledAudioDevices: boolean,
audioInputOutputSelection: boolean,
): void {
const lkRoom = this.connection.livekitRoom;
const syncDevice = (
kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>,
): Subscription =>
selected$.pipe(scope.bind()).subscribe((device) => {
): Subscription => {
return selected$.pipe(scope.bind()).subscribe((device) => {
if (lkRoom.state != LivekitConnectionState.Connected) return;
this.logger.info(
`Selection change for kind: ${kind} selected is ${device?.id}`,
);
// if (this.connectionState$.value !== ConnectionState.Connected) return;
this.logger.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
@@ -355,10 +365,15 @@ export class Publisher {
);
}
});
};
syncDevice("audioinput", devices.audioInput.selected$);
if (!controlledAudioDevices)
syncDevice("audiooutput", devices.audioOutput.selected$);
this.logger.debug(`Syncing initial devices with LiveKit, controlledAudioDevices: ${controlledAudioDevices}, audioInputOutputSelection: ${audioInputOutputSelection}`);
if (audioInputOutputSelection) {
syncDevice("audioinput", devices.audioInput.selected$);
if (!controlledAudioDevices) {
syncDevice("audiooutput", devices.audioOutput.selected$);
}
}
syncDevice("videoinput", devices.videoInput.selected$);
}

View File

@@ -50,6 +50,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
combineLatest(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
(availableRaw, iosDeviceMenu) => {
this.logger.debug("Available device update ", availableRaw);
const available = new Map<string, AudioOutputDeviceLabel>(
availableRaw.map(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {

View File

@@ -0,0 +1,57 @@
/*
Copyright 2026 Element Corp.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { map, startWith } from "rxjs";
import { currentRoute$, type Controls } from "../controls.ts";
import { type Behavior, constant } from "./Behavior.ts";
import type { ObservableScope } from "./ObservableScope.ts";
import {
type AudioOutputDeviceLabel,
type MediaDevice,
type SelectedAudioOutputDevice,
} from "./MediaDevices.ts";
/**
* A special implementation of audio output that allows the hosting application
* to have more control over the device selection process. This is used when the
* `controlledAudioDevices` URL parameter is set, which is currently only true on mobile.
*/
export class IOSNativeControlledAudioOutput implements MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> {
private logger = rootLogger.getChild(
"[MediaDevices IOSNativeControlledAudioOutput]",
);
public readonly available$: Behavior<Map<string, AudioOutputDeviceLabel>> =
constant(new Map());
public select(id: string): void {
this.controls.showNativeAudioDevicePicker?.();
}
public readonly selected$ = this.scope.behavior(
currentRoute$.pipe(
startWith(null),
map((route) => {
if (!route) return undefined;
this.logger.debug(`Selected ${route?.type}`);
return {
id: route.type,
} as SelectedAudioOutputDevice;
}),
),
);
public constructor(
private readonly controls: Controls,
private readonly scope: ObservableScope,
) {}
}

View File

@@ -30,7 +30,7 @@ import { platform } from "../Platform";
import { switchWhen } from "../utils/observable";
import { type Behavior, constant } from "./Behavior";
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput.ts";
import { IOSNativeControlledAudioOutput } from "./IOSNativeControlledAudioOutput.ts";
export type DeviceLabel =
| { type: "name"; name: string }
@@ -376,8 +376,9 @@ export class MediaDevices {
getUrlParams().callIntent,
window.controls,
)
: new IOSControlledAudioOutput(this.usingNames$, this.scope)
: new AudioOutput(this.usingNames$, this.scope);
: new IOSNativeControlledAudioOutput(window.controls, this.scope)
: // : new IOSControlledAudioOutput(this.usingNames$, this.scope)
new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
new VideoInput(this.usingNames$, this.scope);