Add an earpiece overlay

This commit is contained in:
Robin
2025-06-13 13:18:29 -04:00
committed by Timo
parent 49c9f5e769
commit 6383c94f2f
9 changed files with 162 additions and 3 deletions

View File

@@ -79,6 +79,10 @@
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
},
"disconnected_banner": "Connectivity to the server has been lost.",
"earpiece": {
"overlay_title": "Earpiece only mode",
"overlay_description": "Only works while using app"
},
"error": {
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",

View File

@@ -0,0 +1,63 @@
.overlay {
position: fixed;
z-index: var(--overlay-layer);
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--cpd-space-2x);
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay[data-show="true"] {
animation: fade-in 200ms;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
display: none;
}
}
.overlay[data-show="false"] {
animation: fade-out 130ms forwards;
content-visibility: hidden;
pointer-events: none;
}
.overlay::before {
content: '';
position: absolute;
z-index: -1;
inset: 0;
background: var(--cpd-color-bg-canvas-default);
opacity: 0.75;
}
.icon {
margin-block-end: var(--cpd-space-4x);
background: var(--cpd-color-alpha-gray-600);
color: var(--cpd-color-icon-primary);
}
.overlay > h2 {
text-align: center;
margin: 0;
}
.overlay > p {
text-align: center;
}

View File

@@ -0,0 +1,32 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC } from "react";
import { BigIcon, Heading, Text } from "@vector-im/compound-web";
import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import styles from "./EarpieceOverlay.module.css";
interface Props {
show: boolean;
}
export const EarpieceOverlay: FC<Props> = ({ show }) => {
const { t } = useTranslation();
return (
<div className={styles.overlay} data-show={show}>
<BigIcon className={styles.icon}>
<EarpieceIcon aria-hidden />
</BigIcon>
<Heading as="h2" weight="semibold" size="md">
{t("earpiece.overlay_title")}
</Heading>
<Text>{t("earpiece.overlay_description")}</Text>
</div>
);
};

View File

@@ -12,13 +12,15 @@ Please see LICENSE in the repository root for full details.
width: 100%;
overflow-x: hidden;
overflow-y: auto;
--overlay-layer: 1;
--header-footer-layer: 2;
}
.header {
position: sticky;
flex-shrink: 0;
inset-block-start: 0;
z-index: 1;
z-index: var(--header-footer-layer);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
@@ -34,7 +36,7 @@ Please see LICENSE in the repository root for full details.
.footer {
position: sticky;
inset-block-end: 0;
z-index: 1;
z-index: var(--header-footer-layer);
display: grid;
grid-template-columns:
minmax(0, var(--inline-content-inset))

View File

@@ -105,6 +105,8 @@ import { useTypedEventEmitter } from "../useEvents.ts";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -116,6 +118,7 @@ export interface ActiveCallProps
}
export const ActiveCall: FC<ActiveCallProps> = (props) => {
const mediaDevices = useMediaDevices();
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
const { livekitRoom, connState } = useLivekit(
props.rtcSession,
@@ -156,6 +159,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const vm = new CallViewModel(
props.rtcSession,
livekitRoom,
mediaDevices,
props.e2eeSystem,
connStateObservable$,
reactionsReader.raisedHands$,
@@ -167,7 +171,13 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
reactionsReader.destroy();
};
}
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
}, [
props.rtcSession,
livekitRoom,
mediaDevices,
props.e2eeSystem,
connStateObservable$,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -293,6 +303,7 @@ export const InCallView: FC<InCallViewProps> = ({
const gridMode = useObservableEagerState(vm.gridMode$);
const showHeader = useObservableEagerState(vm.showHeader$);
const showFooter = useObservableEagerState(vm.showFooter$);
const earpieceMode = useObservableEagerState(vm.earpieceMode$);
const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking
@@ -728,6 +739,7 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<EarpieceOverlay show={earpieceMode} />
<ReactionsOverlay vm={vm} />
{footer}
{layout.type !== "pip" && (

View File

@@ -35,6 +35,41 @@ exports[`InCallView > rendering > renders 1`] = `
>
<div />
</div>
<div
class="overlay"
data-show="false"
>
<div
class="_content_o77nw_8 icon"
data-size="large"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2c3.93 0 7 3.07 7 7a1 1 0 0 1-2 0c0-2.8-2.2-5-5-5S9 6.2 9 9c0 .93.29 1.98.82 2.94.71 1.29 1.53 1.92 2.32 2.53.92.71 1.88 1.44 2.39 3 .5 1.5 1 2.01 1.71 2.38.2.09.47.15.76.15 1.1 0 2-.9 2-2a1 1 0 1 1 2 0 4 4 0 0 1-5.64 3.65c-1.36-.71-2.13-1.73-2.73-3.55-.32-.98-.9-1.43-1.71-2.05-.87-.67-1.94-1.5-2.85-3.15C7.38 11.65 7 10.26 7 9c0-3.93 3.07-7 7-7"
/>
<path
d="M6.145 1.3a1 1 0 0 1 1.427 1.4A8.97 8.97 0 0 0 5 9c0 2.3.862 4.397 2.281 5.988l.291.312.069.077A1 1 0 0 1 6.22 16.77l-.075-.07-.356-.38A10.96 10.96 0 0 1 3 9c0-2.998 1.2-5.717 3.145-7.7M14 6.5a2.5 2.5 0 0 1 0 5 2.501 2.501 0 0 1 0-5"
/>
</svg>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Earpiece only mode
</h2>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Only works while using app
</p>
</div>
<div
class="container"
/>

View File

@@ -71,6 +71,7 @@ import {
localId,
localRtcMember,
} from "../utils/test-fixtures";
import { type MediaDevices } from "./MediaDevices";
vi.mock("@livekit/components-core");
@@ -262,6 +263,7 @@ function withCallViewModel(
const vm = new CallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
{} as unknown as MediaDevices,
{
kind: E2eeType.PER_PARTICIPANT,
},

View File

@@ -93,6 +93,7 @@ import {
import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type MediaDevices } from "./MediaDevices";
// How long we wait after a focus switch before showing the real participant
// list again
@@ -1246,6 +1247,11 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
/**
* Whether audio is currently being output through the earpiece.
*/
public readonly earpieceMode$ = this.mediaDevices.earpieceMode$;
public readonly reactions$ = this.reactionsSubject$.pipe(
map((v) =>
Object.fromEntries(
@@ -1336,6 +1342,7 @@ export class CallViewModel extends ViewModel {
// A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState$: Observable<ECConnectionState>,
private readonly handsRaisedSubject$: Observable<

View File

@@ -26,6 +26,7 @@ import {
localRtcMember,
} from "./test-fixtures";
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
import { type MediaDevices } from "../state/MediaDevices";
export function getBasicRTCSession(
members: RoomMember[],
@@ -132,6 +133,7 @@ export function getBasicCallViewModelEnvironment(
const vm = new CallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
{ earpieceMode$: of(false) } as unknown as MediaDevices,
{
kind: E2eeType.PER_PARTICIPANT,
},