mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-08 20:09:19 +00:00
WIP on audio route experiment
This commit is contained in:
@@ -196,6 +196,7 @@ export interface UrlConfiguration {
|
|||||||
* Element Call.
|
* Element Call.
|
||||||
*/
|
*/
|
||||||
controlledAudioDevices: boolean;
|
controlledAudioDevices: boolean;
|
||||||
|
audioInputOutputSelection: boolean;
|
||||||
/**
|
/**
|
||||||
* Setting this flag skips the lobby and brings you in the call directly.
|
* 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
|
* 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,
|
allowIceFallback: true,
|
||||||
perParticipantE2EE: true,
|
perParticipantE2EE: true,
|
||||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||||
|
audioInputOutputSelection: platform !== "ios",
|
||||||
skipLobby: true,
|
skipLobby: true,
|
||||||
returnToLobby: false,
|
returnToLobby: false,
|
||||||
sendNotificationType: "notification",
|
sendNotificationType: "notification",
|
||||||
@@ -427,6 +429,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
|||||||
allowIceFallback: false,
|
allowIceFallback: false,
|
||||||
perParticipantE2EE: false,
|
perParticipantE2EE: false,
|
||||||
controlledAudioDevices: false,
|
controlledAudioDevices: false,
|
||||||
|
audioInputOutputSelection: true,
|
||||||
skipLobby: false,
|
skipLobby: false,
|
||||||
returnToLobby: false,
|
returnToLobby: false,
|
||||||
sendNotificationType: undefined,
|
sendNotificationType: undefined,
|
||||||
|
|||||||
60
src/button/AudioRouteButton.tsx
Normal file
60
src/button/AudioRouteButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
MediaMuteAndSwitchButton,
|
MediaMuteAndSwitchButton,
|
||||||
type MenuOptions,
|
type MenuOptions,
|
||||||
} from "./MediaMuteAndSwitchButton";
|
} from "./MediaMuteAndSwitchButton";
|
||||||
|
import { type AudioRoute, RouteType } from "../controls.ts";
|
||||||
|
|
||||||
export interface AudioOutputSwitcher {
|
export interface AudioOutputSwitcher {
|
||||||
targetOutput: string;
|
targetOutput: string;
|
||||||
@@ -90,6 +91,8 @@ export interface FooterProps {
|
|||||||
selectedVideo?: string;
|
selectedVideo?: string;
|
||||||
selectAudioDevice?: (deviceId: string) => void;
|
selectAudioDevice?: (deviceId: string) => void;
|
||||||
selectVideoDevice?: (deviceId: string) => void;
|
selectVideoDevice?: (deviceId: string) => void;
|
||||||
|
|
||||||
|
nativeAudioRoute?: { targetOutput: AudioRoute , switch: () => void };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CallFooter: FC<FooterProps> = ({
|
export const CallFooter: FC<FooterProps> = ({
|
||||||
@@ -112,6 +115,7 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
reactionIdentifier,
|
reactionIdentifier,
|
||||||
reactionData,
|
reactionData,
|
||||||
audioOutputSwitcher,
|
audioOutputSwitcher,
|
||||||
|
nativeAudioRoute,
|
||||||
hangup,
|
hangup,
|
||||||
debugTileLayout,
|
debugTileLayout,
|
||||||
tileStoreGeneration,
|
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
|
// In this PR we just move the button to the bottom bar. We do not yet update its appearance
|
||||||
const audioOutputButton = useMemo(() => {
|
const audioOutputButton = useMemo(() => {
|
||||||
if (audioOutputSwitcher === undefined) return null;
|
|
||||||
return (
|
if (nativeAudioRoute) {
|
||||||
<LoudspeakerButton
|
return (
|
||||||
size={buttonSize}
|
// TODO make a 4 state button to also include the headset option when supported by the OS
|
||||||
onClick={() => audioOutputSwitcher.switch()}
|
<LoudspeakerButton
|
||||||
loudspeakerModeEnabled={audioOutputSwitcher.targetOutput === "earpiece"}
|
size={buttonSize}
|
||||||
/>
|
onClick={() => nativeAudioRoute.switch()}
|
||||||
);
|
loudspeakerModeEnabled={nativeAudioRoute.targetOutput.type === RouteType.speaker}
|
||||||
}, [audioOutputSwitcher, buttonSize]);
|
/>
|
||||||
|
);
|
||||||
|
} 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);
|
if (audioOutputButton) buttons.push(audioOutputButton);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { Subject } from "rxjs";
|
import { Subject } from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
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 {
|
export interface Controls {
|
||||||
canEnterPip(): boolean;
|
canEnterPip(): boolean;
|
||||||
enablePip(): void;
|
enablePip(): void;
|
||||||
@@ -31,6 +43,9 @@ export interface Controls {
|
|||||||
setOutputEnabled(enabled: boolean): void;
|
setOutputEnabled(enabled: boolean): void;
|
||||||
/** @deprecated use showNativeAudioDevicePicker instead*/
|
/** @deprecated use showNativeAudioDevicePicker instead*/
|
||||||
showNativeOutputDevicePicker?: () => void;
|
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 setAudioEnabled$ = new Subject<boolean>();
|
||||||
|
|
||||||
|
export const currentRoute$ = new Subject<AudioRoute | null>();
|
||||||
|
|
||||||
let playbackStartedEmitted = false;
|
let playbackStartedEmitted = false;
|
||||||
export const setPlaybackStarted = (): void => {
|
export const setPlaybackStarted = (): void => {
|
||||||
if (!playbackStartedEmitted) {
|
if (!playbackStartedEmitted) {
|
||||||
@@ -101,6 +118,10 @@ export const setPlaybackStarted = (): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const showNativeAudioDevicePicker = (): void => {
|
||||||
|
window.controls.showNativeAudioDevicePicker?.();
|
||||||
|
};
|
||||||
|
|
||||||
window.controls = {
|
window.controls = {
|
||||||
canEnterPip(): boolean {
|
canEnterPip(): boolean {
|
||||||
return setPipEnabled$.observed;
|
return setPipEnabled$.observed;
|
||||||
@@ -156,6 +177,14 @@ window.controls = {
|
|||||||
setAudioEnabled$.next(enabled);
|
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
|
// wrappers for the deprecated controls fields
|
||||||
setOutputEnabled(enabled: boolean): void {
|
setOutputEnabled(enabled: boolean): void {
|
||||||
this.setAudioEnabled(enabled);
|
this.setAudioEnabled(enabled);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ window.setLKLogLevel = setLKLogLevel;
|
|||||||
initRageshake().catch((e) => {
|
initRageshake().catch((e) => {
|
||||||
logger.error("Failed to initialize rageshake", e);
|
logger.error("Failed to initialize rageshake", e);
|
||||||
});
|
});
|
||||||
setLKLogLevel("info");
|
setLKLogLevel("trace");
|
||||||
setLKLogExtension((level, msg, context) => {
|
setLKLogExtension((level, msg, context) => {
|
||||||
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
|
// 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);
|
global.mx_rage_logger.log(level, "livekit", msg, context);
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const showFooter = useBehavior(vm.showFooter$);
|
const showFooter = useBehavior(vm.showFooter$);
|
||||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||||
|
const nativeAudioRoute = useBehavior(vm.nativeAudioRouteSwitcher$);
|
||||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||||
|
|
||||||
const fatalCallError = useBehavior(vm.fatalError$);
|
const fatalCallError = useBehavior(vm.fatalError$);
|
||||||
@@ -590,6 +591,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||||
reactionData={supportsReactions ? vm : undefined}
|
reactionData={supportsReactions ? vm : undefined}
|
||||||
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
|
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
|
||||||
|
nativeAudioRoute = { nativeAudioRoute ?? undefined}
|
||||||
// Only pass the openSettings function if the settings button is not in the app bar.
|
// 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.
|
// If there is no fn the button will be hidden in the footer.
|
||||||
openSettings={settingsButtonInAppBar ? undefined : openSettings}
|
openSettings={settingsButtonInAppBar ? undefined : openSettings}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { map, startWith } from "rxjs";
|
||||||
|
|
||||||
import inCallStyles from "./InCallView.module.css";
|
import inCallStyles from "./InCallView.module.css";
|
||||||
import styles from "./LobbyView.module.css";
|
import styles from "./LobbyView.module.css";
|
||||||
@@ -48,6 +49,14 @@ import { getValue } from "../utils/observable";
|
|||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
import { CallFooter } from "../components/CallFooter";
|
import { CallFooter } from "../components/CallFooter";
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
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 {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -184,6 +193,39 @@ export const LobbyView: FC<Props> = ({
|
|||||||
|
|
||||||
useTrackProcessorSync(videoTrack);
|
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
|
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||||
// animations and don't have to feel bad about reusing its CSS
|
// animations and don't have to feel bad about reusing its CSS
|
||||||
return (
|
return (
|
||||||
@@ -234,6 +276,7 @@ export const LobbyView: FC<Props> = ({
|
|||||||
toggleVideo={toggleVideo ?? undefined}
|
toggleVideo={toggleVideo ?? undefined}
|
||||||
openSettings={openSettings}
|
openSettings={openSettings}
|
||||||
hangup={!confineToRoom ? onLeaveClick : undefined}
|
hangup={!confineToRoom ? onLeaveClick : undefined}
|
||||||
|
nativeAudioRoute={nativeAudioRoute ?? undefined}
|
||||||
// Logo and header are connected. We will only show the logo in SPA with header.
|
// Logo and header are connected. We will only show the logo in SPA with header.
|
||||||
hideLogo={hideHeader}
|
hideLogo={hideHeader}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ import {
|
|||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
MembershipManagerEvent,
|
|
||||||
type LivekitTransportConfig,
|
type LivekitTransportConfig,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
|
MembershipManagerEvent,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
@@ -64,7 +64,14 @@ import {
|
|||||||
showReactions,
|
showReactions,
|
||||||
} from "../../settings/settings";
|
} from "../../settings/settings";
|
||||||
import { isFirefox, platform } from "../../Platform";
|
import { isFirefox, platform } from "../../Platform";
|
||||||
import { setPipEnabled$ } from "../../controls";
|
import {
|
||||||
|
type AudioRoute,
|
||||||
|
currentRoute$,
|
||||||
|
RouteType,
|
||||||
|
setPipEnabled$,
|
||||||
|
showNativeAudioDevicePicker,
|
||||||
|
showNativeAudioDevicePicker$,
|
||||||
|
} from "../../controls";
|
||||||
import { TileStore } from "../TileStore";
|
import { TileStore } from "../TileStore";
|
||||||
import { gridLikeLayout } from "../GridLikeLayout";
|
import { gridLikeLayout } from "../GridLikeLayout";
|
||||||
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
|
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
|
||||||
@@ -78,7 +85,7 @@ import {
|
|||||||
} from "../../reactions";
|
} from "../../reactions";
|
||||||
import { shallowEquals } from "../../utils/array";
|
import { shallowEquals } from "../../utils/array";
|
||||||
import { type MediaDevices } from "../MediaDevices";
|
import { type MediaDevices } from "../MediaDevices";
|
||||||
import { constant, type Behavior } from "../Behavior";
|
import { type Behavior, constant } from "../Behavior";
|
||||||
import { E2eeType } from "../../e2ee/e2eeType";
|
import { E2eeType } from "../../e2ee/e2eeType";
|
||||||
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
||||||
import { type MuteStates } from "../MuteStates";
|
import { type MuteStates } from "../MuteStates";
|
||||||
@@ -122,8 +129,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
createMatrixLivekitMembers$,
|
createMatrixLivekitMembers$,
|
||||||
type LocalMatrixLivekitMember,
|
type LocalMatrixLivekitMember,
|
||||||
type RemoteMatrixLivekitMember,
|
|
||||||
type MatrixLivekitMember,
|
type MatrixLivekitMember,
|
||||||
|
type RemoteMatrixLivekitMember,
|
||||||
} from "./remoteMembers/MatrixLivekitMembers.ts";
|
} from "./remoteMembers/MatrixLivekitMembers.ts";
|
||||||
import {
|
import {
|
||||||
type AutoLeaveReason,
|
type AutoLeaveReason,
|
||||||
@@ -368,6 +375,14 @@ export interface CallViewModel {
|
|||||||
switch: () => void;
|
switch: () => void;
|
||||||
} | null>;
|
} | 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.
|
* 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.
|
* Emits an array of reactions that should be visible on the screen.
|
||||||
*/
|
*/
|
||||||
@@ -1624,6 +1656,7 @@ export function createCallViewModel$(
|
|||||||
showFooter$: showFooter$,
|
showFooter$: showFooter$,
|
||||||
earpieceMode$: earpieceMode$,
|
earpieceMode$: earpieceMode$,
|
||||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||||
|
nativeAudioRouteSwitcher$: nativeAudioRouteSwitcher$,
|
||||||
reconnecting$: localMembership.reconnecting$,
|
reconnecting$: localMembership.reconnecting$,
|
||||||
livekitRoomItems$,
|
livekitRoomItems$,
|
||||||
connected$: localMembership.connected$,
|
connected$: localMembership.connected$,
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export class Publisher {
|
|||||||
trackerProcessorState$: Behavior<ProcessorState>,
|
trackerProcessorState$: Behavior<ProcessorState>,
|
||||||
private logger: Logger,
|
private logger: Logger,
|
||||||
) {
|
) {
|
||||||
const { controlledAudioDevices } = getUrlParams();
|
const { controlledAudioDevices, audioInputOutputSelection } =
|
||||||
|
getUrlParams();
|
||||||
const room = connection.livekitRoom;
|
const room = connection.livekitRoom;
|
||||||
|
|
||||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||||
@@ -74,7 +75,12 @@ export class Publisher {
|
|||||||
// Setup track processor syncing (blur)
|
// Setup track processor syncing (blur)
|
||||||
this.observeTrackProcessors(this.scope, room, trackerProcessorState$);
|
this.observeTrackProcessors(this.scope, room, trackerProcessorState$);
|
||||||
// Observe media device changes and update LiveKit active devices accordingly
|
// 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);
|
this.workaroundRestartAudioInputTrackChrome(devices, this.scope);
|
||||||
|
|
||||||
@@ -304,7 +310,7 @@ export class Publisher {
|
|||||||
// the process of being restarted.
|
// the process of being restarted.
|
||||||
activeMicTrack.mediaStreamTrack.readyState !== "ended"
|
activeMicTrack.mediaStreamTrack.readyState !== "ended"
|
||||||
) {
|
) {
|
||||||
this.logger?.info(
|
this.logger.info(
|
||||||
"Restarting audio device track due to active media device changed (workaroundRestartAudioInputTrackChrome)",
|
"Restarting audio device track due to active media device changed (workaroundRestartAudioInputTrackChrome)",
|
||||||
);
|
);
|
||||||
// Restart the track, which will cause Livekit to do another
|
// Restart the track, which will cause Livekit to do another
|
||||||
@@ -326,14 +332,18 @@ export class Publisher {
|
|||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
devices: MediaDevices,
|
devices: MediaDevices,
|
||||||
controlledAudioDevices: boolean,
|
controlledAudioDevices: boolean,
|
||||||
|
audioInputOutputSelection: boolean,
|
||||||
): void {
|
): void {
|
||||||
const lkRoom = this.connection.livekitRoom;
|
const lkRoom = this.connection.livekitRoom;
|
||||||
const syncDevice = (
|
const syncDevice = (
|
||||||
kind: MediaDeviceKind,
|
kind: MediaDeviceKind,
|
||||||
selected$: Observable<SelectedDevice | undefined>,
|
selected$: Observable<SelectedDevice | undefined>,
|
||||||
): Subscription =>
|
): Subscription => {
|
||||||
selected$.pipe(scope.bind()).subscribe((device) => {
|
return selected$.pipe(scope.bind()).subscribe((device) => {
|
||||||
if (lkRoom.state != LivekitConnectionState.Connected) return;
|
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;
|
// if (this.connectionState$.value !== ConnectionState.Connected) return;
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
||||||
@@ -355,10 +365,15 @@ export class Publisher {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
syncDevice("audioinput", devices.audioInput.selected$);
|
this.logger.debug(`Syncing initial devices with LiveKit, controlledAudioDevices: ${controlledAudioDevices}, audioInputOutputSelection: ${audioInputOutputSelection}`);
|
||||||
if (!controlledAudioDevices)
|
if (audioInputOutputSelection) {
|
||||||
syncDevice("audiooutput", devices.audioOutput.selected$);
|
syncDevice("audioinput", devices.audioInput.selected$);
|
||||||
|
if (!controlledAudioDevices) {
|
||||||
|
syncDevice("audiooutput", devices.audioOutput.selected$);
|
||||||
|
}
|
||||||
|
}
|
||||||
syncDevice("videoinput", devices.videoInput.selected$);
|
syncDevice("videoinput", devices.videoInput.selected$);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
|
|||||||
combineLatest(
|
combineLatest(
|
||||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||||
(availableRaw, iosDeviceMenu) => {
|
(availableRaw, iosDeviceMenu) => {
|
||||||
|
this.logger.debug("Available device update ", availableRaw);
|
||||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||||
availableRaw.map(
|
availableRaw.map(
|
||||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||||
|
|||||||
57
src/state/IOSNativeControlledAudioOutput.ts
Normal file
57
src/state/IOSNativeControlledAudioOutput.ts
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ import { platform } from "../Platform";
|
|||||||
import { switchWhen } from "../utils/observable";
|
import { switchWhen } from "../utils/observable";
|
||||||
import { type Behavior, constant } from "./Behavior";
|
import { type Behavior, constant } from "./Behavior";
|
||||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
|
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
|
||||||
import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput.ts";
|
import { IOSNativeControlledAudioOutput } from "./IOSNativeControlledAudioOutput.ts";
|
||||||
|
|
||||||
export type DeviceLabel =
|
export type DeviceLabel =
|
||||||
| { type: "name"; name: string }
|
| { type: "name"; name: string }
|
||||||
@@ -376,8 +376,9 @@ export class MediaDevices {
|
|||||||
getUrlParams().callIntent,
|
getUrlParams().callIntent,
|
||||||
window.controls,
|
window.controls,
|
||||||
)
|
)
|
||||||
: new IOSControlledAudioOutput(this.usingNames$, this.scope)
|
: new IOSNativeControlledAudioOutput(window.controls, this.scope)
|
||||||
: new AudioOutput(this.usingNames$, this.scope);
|
: // : new IOSControlledAudioOutput(this.usingNames$, this.scope)
|
||||||
|
new AudioOutput(this.usingNames$, this.scope);
|
||||||
|
|
||||||
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
||||||
new VideoInput(this.usingNames$, this.scope);
|
new VideoInput(this.usingNames$, this.scope);
|
||||||
|
|||||||
Reference in New Issue
Block a user