Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 84cf40e

Browse files
authored
Switch video rooms to spotlight layout when in PiP mode (#8912)
* Switch video rooms to spotlight layout when in PiP mode * Add some comments
1 parent 5c67ef1 commit 84cf40e

File tree

3 files changed

+74
-18
lines changed

3 files changed

+74
-18
lines changed

src/stores/VideoChannelStore.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import defaultDispatcher from "../dispatcher/dispatcher";
2525
import { ActionPayload } from "../dispatcher/payloads";
2626
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
2727
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore";
28+
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore";
2829
import { STUCK_DEVICE_TIMEOUT_MS, getVideoChannel, addOurDevice, removeOurDevice } from "../utils/VideoChannelUtils";
2930
import { timeout } from "../utils/promise";
3031
import WidgetUtils from "../utils/WidgetUtils";
@@ -234,6 +235,8 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
234235
}
235236

236237
this.connected = true;
238+
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
239+
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
237240
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
238241
window.addEventListener("beforeunload", this.setDisconnected);
239242

@@ -264,8 +267,14 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
264267
const roomId = this.roomId;
265268
const room = this.room;
266269

267-
this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
268270
this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
271+
this.activeChannel.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
272+
this.activeChannel.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
273+
this.activeChannel.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
274+
this.activeChannel.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
275+
this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
276+
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
277+
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
269278
room.off(RoomEvent.MyMembership, this.onMyMembership);
270279
window.removeEventListener("beforeunload", this.setDisconnected);
271280
clearInterval(this.resendDevicesTimer);
@@ -324,4 +333,15 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
324333
private onMyMembership = (room: Room, membership: string) => {
325334
if (membership !== "join") this.setDisconnected();
326335
};
336+
337+
private onDock = async () => {
338+
// The widget is no longer a PiP, so let's restore the default layout
339+
await this.activeChannel.transport.send(ElementWidgetActions.TileLayout, {});
340+
};
341+
342+
private onUndock = async () => {
343+
// The widget has become a PiP, so let's switch Jitsi to spotlight mode
344+
// to only show the active speaker and economize on space
345+
await this.activeChannel.transport.send(ElementWidgetActions.SpotlightLayout, {});
346+
};
327347
}

src/stores/widgets/ElementWidgetActions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { IWidgetApiRequest } from "matrix-widget-api";
1919
export enum ElementWidgetActions {
2020
ClientReady = "im.vector.ready",
2121
WidgetReady = "io.element.widget_ready",
22+
23+
// All of these actions are currently specific to Jitsi
2224
JoinCall = "io.element.join",
2325
HangupCall = "im.vector.hangup",
2426
ForceHangupCall = "io.element.force_hangup",
@@ -28,6 +30,10 @@ export enum ElementWidgetActions {
2830
MuteVideo = "io.element.mute_video",
2931
UnmuteVideo = "io.element.unmute_video",
3032
StartLiveStream = "im.vector.start_live_stream",
33+
// Actions for switching layouts
34+
TileLayout = "io.element.tile_layout",
35+
SpotlightLayout = "io.element.spotlight_layout",
36+
3137
OpenIntegrationManager = "integration_manager_open",
3238

3339
/**

test/stores/VideoChannelStore-test.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { mocked } from "jest-mock";
18-
import { Widget, ClientWidgetApi, MatrixWidgetType, IWidgetApiRequest } from "matrix-widget-api";
17+
import { mocked, Mocked } from "jest-mock";
18+
import {
19+
Widget,
20+
ClientWidgetApi,
21+
MatrixWidgetType,
22+
WidgetApiAction,
23+
IWidgetApiRequest,
24+
IWidgetApiRequestData,
25+
} from "matrix-widget-api";
1926
import { MatrixClient } from "matrix-js-sdk/src/client";
2027

2128
import { stubClient, setupAsyncStoreWithClient, mkRoom } from "../test-utils";
2229
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
2330
import WidgetStore, { IApp } from "../../src/stores/WidgetStore";
2431
import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
32+
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore";
2533
import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions";
2634
import { VIDEO_CHANNEL_MEMBER, STUCK_DEVICE_TIMEOUT_MS } from "../../src/utils/VideoChannelUtils";
2735
import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore";
@@ -43,22 +51,19 @@ describe("VideoChannelStore", () => {
4351
} as IApp;
4452

4553
// Set up mocks to simulate the remote end of the widget API
46-
let messageSent: Promise<void>;
47-
let messageSendMock: () => void;
54+
let sendMock: (action: WidgetApiAction, data: IWidgetApiRequestData) => void;
4855
let onMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
4956
let onceMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
5057
let messaging: ClientWidgetApi;
51-
let cli: MatrixClient;
58+
let cli: Mocked<MatrixClient>;
5259
beforeEach(() => {
5360
stubClient();
54-
cli = MatrixClientPeg.get();
61+
cli = mocked(MatrixClientPeg.get());
5562
setupAsyncStoreWithClient(WidgetMessagingStore.instance, cli);
5663
setupAsyncStoreWithClient(store, cli);
57-
mocked(cli).getRoom.mockReturnValue(mkRoom(cli, "!1:example.org"));
64+
cli.getRoom.mockReturnValue(mkRoom(cli, "!1:example.org"));
5865

59-
let resolveMessageSent: () => void;
60-
messageSent = new Promise(resolve => resolveMessageSent = resolve);
61-
messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent());
66+
sendMock = jest.fn();
6267
onMock = jest.fn();
6368
onceMock = jest.fn();
6469

@@ -69,14 +74,19 @@ describe("VideoChannelStore", () => {
6974
stop: () => {},
7075
once: onceMock,
7176
transport: {
72-
send: messageSendMock,
77+
send: sendMock,
7378
reply: () => {},
7479
},
7580
} as unknown as ClientWidgetApi;
7681
});
7782

7883
afterEach(() => jest.useRealTimers());
7984

85+
const getRequest = <T extends IWidgetApiRequestData>(): Promise<[WidgetApiAction, T]> =>
86+
new Promise<[WidgetApiAction, T]>(resolve => {
87+
mocked(sendMock).mockImplementationOnce((action, data) => resolve([action, data as T]));
88+
});
89+
8090
const widgetReady = () => {
8191
// Tell the WidgetStore that the widget is ready
8292
const [, ready] = mocked(onceMock).mock.calls.find(([action]) =>
@@ -87,7 +97,7 @@ describe("VideoChannelStore", () => {
8797

8898
const confirmConnect = async () => {
8999
// Wait for the store to contact the widget API
90-
await messageSent;
100+
await getRequest();
91101
// Then, locate the callback that will confirm the join
92102
const [, join] = mocked(onMock).mock.calls.find(([action]) =>
93103
action === `action:${ElementWidgetActions.JoinCall}`,
@@ -122,8 +132,9 @@ describe("VideoChannelStore", () => {
122132
expect(store.roomId).toBeFalsy();
123133
expect(store.connected).toEqual(false);
124134

135+
const connectConfirmed = confirmConnect();
125136
const connectPromise = store.connect("!1:example.org", null, null);
126-
await confirmConnect();
137+
await connectConfirmed;
127138
await expect(connectPromise).resolves.toBeUndefined();
128139
expect(store.roomId).toEqual("!1:example.org");
129140
expect(store.connected).toEqual(true);
@@ -135,7 +146,7 @@ describe("VideoChannelStore", () => {
135146
{ devices: [cli.getDeviceId()], expires_ts: expect.any(Number) },
136147
cli.getUserId(),
137148
);
138-
mocked(cli).sendStateEvent.mockClear();
149+
cli.sendStateEvent.mockClear();
139150

140151
// Our devices should be resent within the timeout period to prevent
141152
// the data from becoming stale
@@ -146,7 +157,7 @@ describe("VideoChannelStore", () => {
146157
{ devices: [cli.getDeviceId()], expires_ts: expect.any(Number) },
147158
cli.getUserId(),
148159
);
149-
mocked(cli).sendStateEvent.mockClear();
160+
cli.sendStateEvent.mockClear();
150161

151162
const disconnectPromise = store.disconnect();
152163
await confirmDisconnect();
@@ -165,10 +176,11 @@ describe("VideoChannelStore", () => {
165176
});
166177

167178
it("waits for messaging when connecting", async () => {
179+
const connectConfirmed = confirmConnect();
168180
const connectPromise = store.connect("!1:example.org", null, null);
169181
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
170182
widgetReady();
171-
await confirmConnect();
183+
await connectConfirmed;
172184
await expect(connectPromise).resolves.toBeUndefined();
173185
expect(store.roomId).toEqual("!1:example.org");
174186
expect(store.connected).toEqual(true);
@@ -184,12 +196,30 @@ describe("VideoChannelStore", () => {
184196
expect(store.roomId).toBeFalsy();
185197
expect(store.connected).toEqual(false);
186198

199+
const requestPromise = getRequest();
187200
const connectPromise = store.connect("!1:example.org", null, null);
188201
// Wait for the store to contact the widget API, then stop the messaging
189-
await messageSent;
202+
await requestPromise;
190203
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
191204
await expect(connectPromise).rejects.toBeDefined();
192205
expect(store.roomId).toBeFalsy();
193206
expect(store.connected).toEqual(false);
194207
});
208+
209+
it("switches to spotlight mode when the widget becomes a PiP", async () => {
210+
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
211+
widgetReady();
212+
confirmConnect();
213+
await store.connect("!1:example.org", null, null);
214+
215+
const request = getRequest<IWidgetApiRequestData>();
216+
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
217+
const [action, data] = await request;
218+
expect(action).toEqual(ElementWidgetActions.SpotlightLayout);
219+
expect(data).toEqual({});
220+
221+
store.disconnect();
222+
await confirmDisconnect();
223+
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
224+
});
195225
});

0 commit comments

Comments
 (0)