diff --git a/docs/README.md b/docs/README.md index a78c7253..113b52c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,5 +2,6 @@ This folder contains documentation for Element Call setup and usage. -- [Url format and parameters](./url-params.md) - [Embedded vs standalone mode](./embedded-standalone.md) +- [Url format and parameters](./url-params.md) +- [Global JS controls](./controls.md) diff --git a/docs/controls.md b/docs/controls.md new file mode 100644 index 00000000..02df61ef --- /dev/null +++ b/docs/controls.md @@ -0,0 +1,7 @@ +# Global JS controls + +A few aspects of Element Call's interface can be controlled through a global API on the `window`: + +- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode. +- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call. +- `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call. diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index ad0f6570..fcb86147 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,6 +15,7 @@ limitations under the License. */ import "matrix-js-sdk/src/@types/global"; +import { Controls } from "../controls"; declare global { interface Document { @@ -23,6 +24,10 @@ declare global { webkitFullscreenElement: HTMLElement | null; } + interface Window { + controls: Controls; + } + interface HTMLElement { // Safari only supports this prefixed, so tell the type system about it webkitRequestFullscreen: () => void; diff --git a/src/controls.ts b/src/controls.ts new file mode 100644 index 00000000..3f6ecc54 --- /dev/null +++ b/src/controls.ts @@ -0,0 +1,39 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Subject } from "rxjs"; + +export interface Controls { + canEnterPip: () => boolean; + enablePip: () => void; + disablePip: () => void; +} + +export const setPipEnabled = new Subject(); + +window.controls = { + canEnterPip(): boolean { + return setPipEnabled.observed; + }, + enablePip(): void { + if (!setPipEnabled.observed) throw new Error("No call is running"); + setPipEnabled.next(true); + }, + disablePip(): void { + if (!setPipEnabled.observed) throw new Error("No call is running"); + setPipEnabled.next(false); + }, +}; diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 32d34fb7..cfc436c9 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -58,6 +58,10 @@ limitations under the License. ); } +.footer.hidden { + display: none; +} + .footer.overlay { position: absolute; inset-block-end: 0; @@ -67,6 +71,7 @@ limitations under the License. } .footer.overlay.hidden { + display: grid; opacity: 0; pointer-events: none; } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index fcd63fda..22b6be8c 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -69,7 +69,7 @@ import { InviteButton } from "../button/InviteButton"; import { LayoutToggle } from "./LayoutToggle"; import { ECConnectionState } from "../livekit/useECConnectionState"; import { useOpenIDSFU } from "../livekit/openIDSFU"; -import { GridMode, Layout, useCallViewModel } from "../state/CallViewModel"; +import { CallViewModel, GridMode, Layout } from "../state/CallViewModel"; import { Grid, TileProps } from "../grid/Grid"; import { useObservable } from "../state/useObservable"; import { useInitial } from "../useInitial"; @@ -93,7 +93,7 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const maxTapDurationMs = 400; export interface ActiveCallProps - extends Omit { + extends Omit { e2eeSystem: EncryptionSystem; } @@ -105,6 +105,8 @@ export const ActiveCall: FC = (props) => { sfuConfig, props.e2eeSystem, ); + const connStateObservable = useObservable(connState); + const [vm, setVm] = useState(null); useEffect(() => { return (): void => { @@ -113,17 +115,41 @@ export const ActiveCall: FC = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (!livekitRoom) return null; + useEffect(() => { + if (livekitRoom !== undefined) { + const vm = new CallViewModel( + props.rtcSession.room, + livekitRoom, + props.e2eeSystem.kind !== E2eeType.NONE, + connStateObservable, + ); + setVm(vm); + return (): void => vm.destroy(); + } + }, [ + props.rtcSession.room, + livekitRoom, + props.e2eeSystem.kind, + connStateObservable, + ]); + + if (livekitRoom === undefined || vm === null) return null; return ( - + ); }; export interface InCallViewProps { client: MatrixClient; + vm: CallViewModel; matrixInfo: MatrixInfo; rtcSession: MatrixRTCSession; livekitRoom: Room; @@ -138,6 +164,7 @@ export interface InCallViewProps { export const InCallView: FC = ({ client, + vm, matrixInfo, rtcSession, livekitRoom, @@ -193,12 +220,6 @@ export const InCallView: FC = ({ const reducedControls = boundsValid && bounds.width <= 340; const noControls = reducedControls && bounds.height <= 400; - const vm = useCallViewModel( - rtcSession.room, - livekitRoom, - matrixInfo.e2eeSystem.kind !== E2eeType.NONE, - connState, - ); const windowMode = useObservableEagerState(vm.windowMode); const layout = useObservableEagerState(vm.layout); const gridMode = useObservableEagerState(vm.gridMode); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index c68869f9..0ba14845 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -26,7 +26,6 @@ import { RemoteParticipant, } from "livekit-client"; import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix"; -import { useEffect, useRef } from "react"; import { EMPTY, Observable, @@ -44,7 +43,6 @@ import { race, sample, scan, - shareReplay, skip, startWith, switchAll, @@ -58,12 +56,10 @@ import { import { logger } from "matrix-js-sdk/src/logger"; import { ViewModel } from "./ViewModel"; -import { useObservable } from "./useObservable"; import { ECAddonConnectionState, ECConnectionState, } from "../livekit/useECConnectionState"; -import { usePrevious } from "../usePrevious"; import { LocalUserMediaViewModel, MediaViewModel, @@ -75,6 +71,7 @@ import { accumulate, finalizeValue } from "../observable-utils"; import { ObservableScope } from "./ObservableScope"; import { duplicateTiles } from "../settings/settings"; import { isFirefox } from "../Platform"; +import { setPipEnabled } from "../controls"; // How long we wait after a focus switch before showing the real participant // list again @@ -194,11 +191,9 @@ class UserMedia { ), ), startWith(false), - distinctUntilChanged(), - this.scope.bind(), // Make this Observable hot so that the timers don't reset when you // resubscribe - shareReplay(1), + this.scope.state(), ); this.presenter = observeParticipantEvents( @@ -261,7 +256,7 @@ function findMatrixMember( export class CallViewModel extends ViewModel { private readonly rawRemoteParticipants = connectedParticipantsObserver( this.livekitRoom, - ).pipe(shareReplay(1)); + ).pipe(this.scope.state()); // Lists of participants to "hold" on display, even if LiveKit claims that // they've left @@ -383,7 +378,7 @@ export class CallViewModel extends ViewModel { finalizeValue((ts) => { for (const t of ts) t.destroy(); }), - shareReplay(1), + this.scope.state(), ); private readonly userMedia: Observable = this.mediaItems.pipe( @@ -402,7 +397,7 @@ export class CallViewModel extends ViewModel { map((mediaItems) => mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), ), - shareReplay(1), + this.scope.state(), ); private readonly hasRemoteScreenShares: Observable = @@ -443,9 +438,8 @@ export class CallViewModel extends ViewModel { }, null, ), - distinctUntilChanged(), map((speaker) => speaker.vm), - shareReplay(1), + this.scope.state(), throttleTime(1600, undefined, { leading: true, trailing: true }), ); @@ -513,16 +507,17 @@ export class CallViewModel extends ViewModel { private readonly spotlight: Observable = this.spotlightAndPip.pipe( switchMap(([spotlight]) => spotlight), - shareReplay(1), + this.scope.state(), ); private readonly pip: Observable = this.spotlightAndPip.pipe(switchMap(([, pip]) => pip)); - /** - * The general shape of the window. - */ - public readonly windowMode: Observable = fromEvent( + private readonly pipEnabled: Observable = setPipEnabled.pipe( + startWith(false), + ); + + private readonly naturalWindowMode: Observable = fromEvent( window, "resize", ).pipe( @@ -538,15 +533,21 @@ export class CallViewModel extends ViewModel { if (width <= 600) return "narrow"; return "normal"; }), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), + ); + + /** + * The general shape of the window. + */ + public readonly windowMode: Observable = this.pipEnabled.pipe( + switchMap((pip) => (pip ? of("pip") : this.naturalWindowMode)), ); private readonly spotlightExpandedToggle = new Subject(); public readonly spotlightExpanded: Observable = this.spotlightExpandedToggle.pipe( accumulate(false, (expanded) => !expanded), - shareReplay(1), + this.scope.state(), ); private readonly gridModeUserSelection = new Subject(); @@ -572,8 +573,7 @@ export class CallViewModel extends ViewModel { ) ).pipe(startWith(userSelection ?? "grid")), ), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); public setGridMode(value: GridMode): void { @@ -629,7 +629,7 @@ export class CallViewModel extends ViewModel { ); private readonly pipLayout: Observable = this.spotlight.pipe( - map((spotlight): Layout => ({ type: "pip", spotlight })), + map((spotlight) => ({ type: "pip", spotlight })), ); public readonly layout: Observable = this.windowMode.pipe( @@ -690,13 +690,12 @@ export class CallViewModel extends ViewModel { return this.pipLayout; } }), - shareReplay(1), + this.scope.state(), ); public showSpotlightIndicators: Observable = this.layout.pipe( map((l) => l.type !== "grid"), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); /** @@ -720,8 +719,7 @@ export class CallViewModel extends ViewModel { public showSpeakingIndicators: Observable = this.layout.pipe( map((l) => l.type !== "one-on-one" && !l.type.startsWith("spotlight-")), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); public readonly toggleSpotlightExpanded: Observable<(() => void) | null> = @@ -741,7 +739,7 @@ export class CallViewModel extends ViewModel { map((enabled) => enabled ? (): void => this.spotlightExpandedToggle.next() : null, ), - shareReplay(1), + this.scope.state(), ); private readonly screenTap = new Subject(); @@ -771,8 +769,7 @@ export class CallViewModel extends ViewModel { public readonly showHeader: Observable = this.windowMode.pipe( map((mode) => mode !== "pip" && mode !== "flat"), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); public readonly showFooter = this.windowMode.pipe( @@ -815,8 +812,7 @@ export class CallViewModel extends ViewModel { ); } }), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); public constructor( @@ -829,34 +825,3 @@ export class CallViewModel extends ViewModel { super(); } } - -export function useCallViewModel( - matrixRoom: MatrixRoom, - livekitRoom: LivekitRoom, - encrypted: boolean, - connectionState: ECConnectionState, -): CallViewModel { - const prevMatrixRoom = usePrevious(matrixRoom); - const prevLivekitRoom = usePrevious(livekitRoom); - const prevEncrypted = usePrevious(encrypted); - const connectionStateObservable = useObservable(connectionState); - - const vm = useRef(); - if ( - matrixRoom !== prevMatrixRoom || - livekitRoom !== prevLivekitRoom || - encrypted !== prevEncrypted - ) { - vm.current?.destroy(); - vm.current = new CallViewModel( - matrixRoom, - livekitRoom, - encrypted, - connectionStateObservable, - ); - } - - useEffect(() => vm.current?.destroy(), []); - - return vm.current!; -} diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 197f0341..ff262996 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -36,12 +36,10 @@ import { BehaviorSubject, Observable, combineLatest, - distinctUntilChanged, distinctUntilKeyChanged, fromEvent, map, of, - shareReplay, startWith, switchMap, } from "rxjs"; @@ -84,7 +82,6 @@ function observeTrackReference( source, })), distinctUntilKeyChanged("publication"), - shareReplay(1), ); } @@ -119,15 +116,19 @@ abstract class BaseMediaViewModel extends ViewModel { videoSource: VideoSource, ) { super(); - const audio = observeTrackReference(participant, audioSource); - this.video = observeTrackReference(participant, videoSource); + const audio = observeTrackReference(participant, audioSource).pipe( + this.scope.state(), + ); + this.video = observeTrackReference(participant, videoSource).pipe( + this.scope.state(), + ); this.unencryptedWarning = combineLatest( [audio, this.video], (a, v) => callEncrypted && (a.publication?.isEncrypted === false || v.publication?.isEncrypted === false), - ).pipe(distinctUntilChanged(), shareReplay(1)); + ).pipe(this.scope.state()); } } @@ -151,7 +152,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { ParticipantEvent.IsSpeakingChanged, ).pipe( map((p) => p.isSpeaking), - shareReplay(1), + this.scope.state(), ); /** @@ -184,7 +185,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { Track.Source.Camera, ); - const media = observeParticipantMedia(participant).pipe(shareReplay(1)); + const media = observeParticipantMedia(participant).pipe(this.scope.state()); this.audioEnabled = media.pipe( map((m) => m.microphoneTrack?.isMuted === false), ); @@ -216,7 +217,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { map(() => facingModeFromLocalTrack(track).facingMode === "user"), ); }), - shareReplay(1), + this.scope.state(), ); /** diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index cb7cbd17..813e064c 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -14,7 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MonoTypeOperatorFunction, Subject, takeUntil } from "rxjs"; +import { + distinctUntilChanged, + Observable, + shareReplay, + Subject, + takeUntil, +} from "rxjs"; + +type MonoTypeOperator = (o: Observable) => Observable; /** * A scope which limits the execution lifetime of its bound Observables. @@ -22,12 +30,26 @@ import { MonoTypeOperatorFunction, Subject, takeUntil } from "rxjs"; export class ObservableScope { private readonly ended = new Subject(); + private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended); + /** * Binds an Observable to this scope, so that it completes when the scope * ends. */ - public bind(): MonoTypeOperatorFunction { - return takeUntil(this.ended); + public bind(): MonoTypeOperator { + return this.bindImpl; + } + + private readonly stateImpl: MonoTypeOperator = (o) => + o.pipe(this.bind(), distinctUntilChanged(), shareReplay(1)); + + /** + * Transforms an Observable into a hot state Observable which replays its + * latest value upon subscription, skips updates with identical values, and + * is bound to this scope. + */ + public state(): MonoTypeOperator { + return this.stateImpl; } /**