diff --git a/locales/en/app.json b/locales/en/app.json index e8a86fcc..685b3c4a 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -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", diff --git a/src/room/EarpieceOverlay.module.css b/src/room/EarpieceOverlay.module.css new file mode 100644 index 00000000..f6b8464a --- /dev/null +++ b/src/room/EarpieceOverlay.module.css @@ -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; +} diff --git a/src/room/EarpieceOverlay.tsx b/src/room/EarpieceOverlay.tsx new file mode 100644 index 00000000..f1f13941 --- /dev/null +++ b/src/room/EarpieceOverlay.tsx @@ -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 = ({ show }) => { + const { t } = useTranslation(); + return ( +
+ + + + + {t("earpiece.overlay_title")} + + {t("earpiece.overlay_description")} +
+ ); +}; diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index bb9cc052..8275e094 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -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)) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 136db0b9..a9492930 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 = (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 = (props) => { const vm = new CallViewModel( props.rtcSession, livekitRoom, + mediaDevices, props.e2eeSystem, connStateObservable$, reactionsReader.raisedHands$, @@ -167,7 +171,13 @@ export const ActiveCall: FC = (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 = ({ 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 = ({ {renderContent()} + {footer} {layout.type !== "pip" && ( diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 8ea66705..212283ec 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -35,6 +35,41 @@ exports[`InCallView > rendering > renders 1`] = ` >
+
+
+ +
+

+ Earpiece only mode +

+

+ Only works while using app +

+
diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index b2ecd6cb..3eb5354e 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -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, }, diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 8fd6f819..9b784f16 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -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, private readonly handsRaisedSubject$: Observable< diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index c8a93c73..16a516f2 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -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, },