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

Commit

Permalink
Switch video rooms to spotlight layout when in PiP mode (#8912)
Browse files Browse the repository at this point in the history
* Switch video rooms to spotlight layout when in PiP mode

* Add some comments
  • Loading branch information
robintown authored Jun 27, 2022
1 parent 5c67ef1 commit 84cf40e
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 18 deletions.
22 changes: 21 additions & 1 deletion src/stores/VideoChannelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore";
import { STUCK_DEVICE_TIMEOUT_MS, getVideoChannel, addOurDevice, removeOurDevice } from "../utils/VideoChannelUtils";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
Expand Down Expand Up @@ -234,6 +235,8 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
}

this.connected = true;
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.setDisconnected);

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

this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
this.activeChannel.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
this.activeChannel.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
this.activeChannel.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
this.activeChannel.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.setDisconnected);
clearInterval(this.resendDevicesTimer);
Expand Down Expand Up @@ -324,4 +333,15 @@ export default class VideoChannelStore extends AsyncStoreWithClient<null> {
private onMyMembership = (room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};

private onDock = async () => {
// The widget is no longer a PiP, so let's restore the default layout
await this.activeChannel.transport.send(ElementWidgetActions.TileLayout, {});
};

private onUndock = async () => {
// The widget has become a PiP, so let's switch Jitsi to spotlight mode
// to only show the active speaker and economize on space
await this.activeChannel.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
}
6 changes: 6 additions & 0 deletions src/stores/widgets/ElementWidgetActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions {
ClientReady = "im.vector.ready",
WidgetReady = "io.element.widget_ready",

// All of these actions are currently specific to Jitsi
JoinCall = "io.element.join",
HangupCall = "im.vector.hangup",
ForceHangupCall = "io.element.force_hangup",
Expand All @@ -28,6 +30,10 @@ export enum ElementWidgetActions {
MuteVideo = "io.element.mute_video",
UnmuteVideo = "io.element.unmute_video",
StartLiveStream = "im.vector.start_live_stream",
// Actions for switching layouts
TileLayout = "io.element.tile_layout",
SpotlightLayout = "io.element.spotlight_layout",

OpenIntegrationManager = "integration_manager_open",

/**
Expand Down
64 changes: 47 additions & 17 deletions test/stores/VideoChannelStore-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { mocked } from "jest-mock";
import { Widget, ClientWidgetApi, MatrixWidgetType, IWidgetApiRequest } from "matrix-widget-api";
import { mocked, Mocked } from "jest-mock";
import {
Widget,
ClientWidgetApi,
MatrixWidgetType,
WidgetApiAction,
IWidgetApiRequest,
IWidgetApiRequestData,
} from "matrix-widget-api";
import { MatrixClient } from "matrix-js-sdk/src/client";

import { stubClient, setupAsyncStoreWithClient, mkRoom } from "../test-utils";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import WidgetStore, { IApp } from "../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore";
import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions";
import { VIDEO_CHANNEL_MEMBER, STUCK_DEVICE_TIMEOUT_MS } from "../../src/utils/VideoChannelUtils";
import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore";
Expand All @@ -43,22 +51,19 @@ describe("VideoChannelStore", () => {
} as IApp;

// Set up mocks to simulate the remote end of the widget API
let messageSent: Promise<void>;
let messageSendMock: () => void;
let sendMock: (action: WidgetApiAction, data: IWidgetApiRequestData) => void;
let onMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
let onceMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
let messaging: ClientWidgetApi;
let cli: MatrixClient;
let cli: Mocked<MatrixClient>;
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.get();
cli = mocked(MatrixClientPeg.get());
setupAsyncStoreWithClient(WidgetMessagingStore.instance, cli);
setupAsyncStoreWithClient(store, cli);
mocked(cli).getRoom.mockReturnValue(mkRoom(cli, "!1:example.org"));
cli.getRoom.mockReturnValue(mkRoom(cli, "!1:example.org"));

let resolveMessageSent: () => void;
messageSent = new Promise(resolve => resolveMessageSent = resolve);
messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent());
sendMock = jest.fn();
onMock = jest.fn();
onceMock = jest.fn();

Expand All @@ -69,14 +74,19 @@ describe("VideoChannelStore", () => {
stop: () => {},
once: onceMock,
transport: {
send: messageSendMock,
send: sendMock,
reply: () => {},
},
} as unknown as ClientWidgetApi;
});

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

const getRequest = <T extends IWidgetApiRequestData>(): Promise<[WidgetApiAction, T]> =>
new Promise<[WidgetApiAction, T]>(resolve => {
mocked(sendMock).mockImplementationOnce((action, data) => resolve([action, data as T]));
});

const widgetReady = () => {
// Tell the WidgetStore that the widget is ready
const [, ready] = mocked(onceMock).mock.calls.find(([action]) =>
Expand All @@ -87,7 +97,7 @@ describe("VideoChannelStore", () => {

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

const connectConfirmed = confirmConnect();
const connectPromise = store.connect("!1:example.org", null, null);
await confirmConnect();
await connectConfirmed;
await expect(connectPromise).resolves.toBeUndefined();
expect(store.roomId).toEqual("!1:example.org");
expect(store.connected).toEqual(true);
Expand All @@ -135,7 +146,7 @@ describe("VideoChannelStore", () => {
{ devices: [cli.getDeviceId()], expires_ts: expect.any(Number) },
cli.getUserId(),
);
mocked(cli).sendStateEvent.mockClear();
cli.sendStateEvent.mockClear();

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

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

it("waits for messaging when connecting", async () => {
const connectConfirmed = confirmConnect();
const connectPromise = store.connect("!1:example.org", null, null);
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
widgetReady();
await confirmConnect();
await connectConfirmed;
await expect(connectPromise).resolves.toBeUndefined();
expect(store.roomId).toEqual("!1:example.org");
expect(store.connected).toEqual(true);
Expand All @@ -184,12 +196,30 @@ describe("VideoChannelStore", () => {
expect(store.roomId).toBeFalsy();
expect(store.connected).toEqual(false);

const requestPromise = getRequest();
const connectPromise = store.connect("!1:example.org", null, null);
// Wait for the store to contact the widget API, then stop the messaging
await messageSent;
await requestPromise;
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
await expect(connectPromise).rejects.toBeDefined();
expect(store.roomId).toBeFalsy();
expect(store.connected).toEqual(false);
});

it("switches to spotlight mode when the widget becomes a PiP", async () => {
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
widgetReady();
confirmConnect();
await store.connect("!1:example.org", null, null);

const request = getRequest<IWidgetApiRequestData>();
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
const [action, data] = await request;
expect(action).toEqual(ElementWidgetActions.SpotlightLayout);
expect(data).toEqual({});

store.disconnect();
await confirmDisconnect();
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
});
});

0 comments on commit 84cf40e

Please sign in to comment.