Skip to content

Commit 3a75447

Browse files
authored
Add simple global controls to put the call in picture-in-picture mode (#2573)
* Stop sharing state observables when the view model is destroyed By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior. * Hydrate the call view model in a less hacky way This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix. * Add simple global controls to put the call in picture-in-picture mode Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…) To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only. * Fix footer appearing in large PiP views * Add a method for whether you can enter picture-in-picture mode * Have the controls emit booleans directly
1 parent 0e3113e commit 3a75447

9 files changed

+153
-87
lines changed

docs/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
This folder contains documentation for Element Call setup and usage.
44

5-
- [Url format and parameters](./url-params.md)
65
- [Embedded vs standalone mode](./embedded-standalone.md)
6+
- [Url format and parameters](./url-params.md)
7+
- [Global JS controls](./controls.md)

docs/controls.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Global JS controls
2+
3+
A few aspects of Element Call's interface can be controlled through a global API on the `window`:
4+
5+
- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode.
6+
- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call.
7+
- `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.

src/@types/global.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import "matrix-js-sdk/src/@types/global";
18+
import { Controls } from "../controls";
1819

1920
declare global {
2021
interface Document {
@@ -23,6 +24,10 @@ declare global {
2324
webkitFullscreenElement: HTMLElement | null;
2425
}
2526

27+
interface Window {
28+
controls: Controls;
29+
}
30+
2631
interface HTMLElement {
2732
// Safari only supports this prefixed, so tell the type system about it
2833
webkitRequestFullscreen: () => void;

src/controls.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
Copyright 2024 New Vector Ltd
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { Subject } from "rxjs";
18+
19+
export interface Controls {
20+
canEnterPip: () => boolean;
21+
enablePip: () => void;
22+
disablePip: () => void;
23+
}
24+
25+
export const setPipEnabled = new Subject<boolean>();
26+
27+
window.controls = {
28+
canEnterPip(): boolean {
29+
return setPipEnabled.observed;
30+
},
31+
enablePip(): void {
32+
if (!setPipEnabled.observed) throw new Error("No call is running");
33+
setPipEnabled.next(true);
34+
},
35+
disablePip(): void {
36+
if (!setPipEnabled.observed) throw new Error("No call is running");
37+
setPipEnabled.next(false);
38+
},
39+
};

src/room/InCallView.module.css

+5
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ limitations under the License.
5858
);
5959
}
6060

61+
.footer.hidden {
62+
display: none;
63+
}
64+
6165
.footer.overlay {
6266
position: absolute;
6367
inset-block-end: 0;
@@ -67,6 +71,7 @@ limitations under the License.
6771
}
6872

6973
.footer.overlay.hidden {
74+
display: grid;
7075
opacity: 0;
7176
pointer-events: none;
7277
}

src/room/InCallView.tsx

+31-10
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ import { InviteButton } from "../button/InviteButton";
6969
import { LayoutToggle } from "./LayoutToggle";
7070
import { ECConnectionState } from "../livekit/useECConnectionState";
7171
import { useOpenIDSFU } from "../livekit/openIDSFU";
72-
import { GridMode, Layout, useCallViewModel } from "../state/CallViewModel";
72+
import { CallViewModel, GridMode, Layout } from "../state/CallViewModel";
7373
import { Grid, TileProps } from "../grid/Grid";
7474
import { useObservable } from "../state/useObservable";
7575
import { useInitial } from "../useInitial";
@@ -93,7 +93,7 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
9393
const maxTapDurationMs = 400;
9494

9595
export interface ActiveCallProps
96-
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
96+
extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> {
9797
e2eeSystem: EncryptionSystem;
9898
}
9999

@@ -105,6 +105,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
105105
sfuConfig,
106106
props.e2eeSystem,
107107
);
108+
const connStateObservable = useObservable(connState);
109+
const [vm, setVm] = useState<CallViewModel | null>(null);
108110

109111
useEffect(() => {
110112
return (): void => {
@@ -113,17 +115,41 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
113115
// eslint-disable-next-line react-hooks/exhaustive-deps
114116
}, []);
115117

116-
if (!livekitRoom) return null;
118+
useEffect(() => {
119+
if (livekitRoom !== undefined) {
120+
const vm = new CallViewModel(
121+
props.rtcSession.room,
122+
livekitRoom,
123+
props.e2eeSystem.kind !== E2eeType.NONE,
124+
connStateObservable,
125+
);
126+
setVm(vm);
127+
return (): void => vm.destroy();
128+
}
129+
}, [
130+
props.rtcSession.room,
131+
livekitRoom,
132+
props.e2eeSystem.kind,
133+
connStateObservable,
134+
]);
135+
136+
if (livekitRoom === undefined || vm === null) return null;
117137

118138
return (
119139
<RoomContext.Provider value={livekitRoom}>
120-
<InCallView {...props} livekitRoom={livekitRoom} connState={connState} />
140+
<InCallView
141+
{...props}
142+
vm={vm}
143+
livekitRoom={livekitRoom}
144+
connState={connState}
145+
/>
121146
</RoomContext.Provider>
122147
);
123148
};
124149

125150
export interface InCallViewProps {
126151
client: MatrixClient;
152+
vm: CallViewModel;
127153
matrixInfo: MatrixInfo;
128154
rtcSession: MatrixRTCSession;
129155
livekitRoom: Room;
@@ -138,6 +164,7 @@ export interface InCallViewProps {
138164

139165
export const InCallView: FC<InCallViewProps> = ({
140166
client,
167+
vm,
141168
matrixInfo,
142169
rtcSession,
143170
livekitRoom,
@@ -193,12 +220,6 @@ export const InCallView: FC<InCallViewProps> = ({
193220
const reducedControls = boundsValid && bounds.width <= 340;
194221
const noControls = reducedControls && bounds.height <= 400;
195222

196-
const vm = useCallViewModel(
197-
rtcSession.room,
198-
livekitRoom,
199-
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
200-
connState,
201-
);
202223
const windowMode = useObservableEagerState(vm.windowMode);
203224
const layout = useObservableEagerState(vm.layout);
204225
const gridMode = useObservableEagerState(vm.gridMode);

0 commit comments

Comments
 (0)