diff --git a/src/events/getReferenceRelationsForEvent.ts b/src/events/getReferenceRelationsForEvent.ts new file mode 100644 index 0000000000..7f68e9e5fd --- /dev/null +++ b/src/events/getReferenceRelationsForEvent.ts @@ -0,0 +1,33 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 { EventType, MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { Relations } from "matrix-js-sdk/src/models/relations"; + +import { VoiceBroadcastInfoEventType } from "../voice-broadcast"; + +export const getReferenceRelationsForEvent = ( + event: MatrixEvent, + messageType: EventType | typeof VoiceBroadcastInfoEventType, + client: MatrixClient, +): Relations | undefined => { + const room = client.getRoom(event.getRoomId()); + return room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( + event.getId(), + RelationType.Reference, + messageType, + ); +}; diff --git a/src/events/index.ts b/src/events/index.ts index 67ebedbb4d..8d47a94399 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -16,3 +16,4 @@ limitations under the License. export { getForwardableEvent } from './forward/getForwardableEvent'; export { getShareableLocationEvent } from './location/getShareableLocationEvent'; +export * from "./getReferenceRelationsForEvent"; diff --git a/src/voice-broadcast/components/VoiceBroadcastBody.tsx b/src/voice-broadcast/components/VoiceBroadcastBody.tsx index b90448de8a..3bd0dd6ed1 100644 --- a/src/voice-broadcast/components/VoiceBroadcastBody.tsx +++ b/src/voice-broadcast/components/VoiceBroadcastBody.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { VoiceBroadcastRecordingBody, @@ -28,15 +28,11 @@ import { } from ".."; import { IBodyProps } from "../../components/views/messages/IBodyProps"; import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { getReferenceRelationsForEvent } from "../../events"; export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { const client = MatrixClientPeg.get(); - const room = client.getRoom(mxEvent.getRoomId()); - const relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( - mxEvent.getId(), - RelationType.Reference, - VoiceBroadcastInfoEventType, - ); + const relations = getReferenceRelationsForEvent(mxEvent, VoiceBroadcastInfoEventType, client); const relatedEvents = relations?.getRelations(); const state = !relatedEvents?.find((event: MatrixEvent) => { return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; @@ -49,7 +45,7 @@ export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { />; } - const playback = VoiceBroadcastPlaybacksStore.instance().getByInfoEvent(mxEvent); + const playback = VoiceBroadcastPlaybacksStore.instance().getByInfoEvent(mxEvent, client); return ; diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts index 67a3e7c626..5abc87e01f 100644 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -14,10 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + EventType, + MatrixClient, + MatrixEvent, + MatrixEventEvent, + MsgType, + RelationType, +} from "matrix-js-sdk/src/matrix"; +import { Relations, RelationsEvent } from "matrix-js-sdk/src/models/relations"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; +import { Playback, PlaybackState } from "../../audio/Playback"; +import { PlaybackManager } from "../../audio/PlaybackManager"; +import { getReferenceRelationsForEvent } from "../../events"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import { MediaEventHelper } from "../../utils/MediaEventHelper"; import { IDestroyable } from "../../utils/IDestroyable"; +import { VoiceBroadcastChunkEventType } from ".."; export enum VoiceBroadcastPlaybackState { Paused, @@ -26,10 +40,12 @@ export enum VoiceBroadcastPlaybackState { } export enum VoiceBroadcastPlaybackEvent { + LengthChanged = "length_changed", StateChanged = "state_changed", } interface EventMap { + [VoiceBroadcastPlaybackEvent.LengthChanged]: (length: number) => void; [VoiceBroadcastPlaybackEvent.StateChanged]: (state: VoiceBroadcastPlaybackState) => void; } @@ -37,40 +53,203 @@ export class VoiceBroadcastPlayback extends TypedEventEmitter implements IDestroyable { private state = VoiceBroadcastPlaybackState.Stopped; + private chunkEvents = new Map(); + /** Holds the playback qeue with a 1-based index (sequence number) */ + private queue: Playback[] = []; + private currentlyPlaying: Playback; + private relations: Relations; public constructor( public readonly infoEvent: MatrixEvent, + private client: MatrixClient, ) { super(); + this.setUpRelations(); } - public start() { - this.setState(VoiceBroadcastPlaybackState.Playing); + private addChunkEvent(event: MatrixEvent): boolean { + const eventId = event.getId(); + + if (!eventId + || eventId.startsWith("~!") // don't add local events + || event.getContent()?.msgtype !== MsgType.Audio // don't add non-audio event + || this.chunkEvents.has(eventId)) { + return false; + } + + this.chunkEvents.set(eventId, event); + return true; } - public stop() { - this.setState(VoiceBroadcastPlaybackState.Stopped); + private setUpRelations(): void { + const relations = getReferenceRelationsForEvent(this.infoEvent, EventType.RoomMessage, this.client); + + if (!relations) { + // No related events, yet. Set up relation watcher. + this.infoEvent.on(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + return; + } + + this.relations = relations; + relations.getRelations()?.forEach(e => this.addChunkEvent(e)); + relations.on(RelationsEvent.Add, this.onRelationsEventAdd); + + if (this.chunkEvents.size > 0) { + this.emitLengthChanged(); + } } - public toggle() { - if (this.state === VoiceBroadcastPlaybackState.Stopped) { - this.setState(VoiceBroadcastPlaybackState.Playing); + private onRelationsEventAdd = (event: MatrixEvent) => { + if (this.addChunkEvent(event)) { + this.emitLengthChanged(); + } + }; + + private emitLengthChanged(): void { + this.emit(VoiceBroadcastPlaybackEvent.LengthChanged, this.chunkEvents.size); + } + + private onRelationsCreated = (relationType: string) => { + if (relationType !== RelationType.Reference) { + return; + } + + this.infoEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + this.setUpRelations(); + }; + + private async loadChunks(): Promise { + const relations = getReferenceRelationsForEvent(this.infoEvent, EventType.RoomMessage, this.client); + const chunkEvents = relations?.getRelations(); + + if (!chunkEvents) { + return; + } + + for (const chunkEvent of chunkEvents) { + await this.enqueueChunk(chunkEvent); + } + } + + private async enqueueChunk(chunkEvent: MatrixEvent) { + const sequenceNumber = parseInt(chunkEvent.getContent()?.[VoiceBroadcastChunkEventType]?.sequence, 10); + if (isNaN(sequenceNumber)) return; + + const helper = new MediaEventHelper(chunkEvent); + const blob = await helper.sourceBlob.value; + const buffer = await blob.arrayBuffer(); + const playback = PlaybackManager.instance.createPlaybackInstance(buffer); + await playback.prepare(); + playback.clockInfo.populatePlaceholdersFrom(chunkEvent); + this.queue[sequenceNumber] = playback; + playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, state)); + } + + private onPlaybackStateChange(playback: Playback, newState: PlaybackState) { + if (newState !== PlaybackState.Stopped) { + return; + } + + const next = this.queue[this.queue.indexOf(playback) + 1]; + + if (next) { + this.currentlyPlaying = next; + next.play(); return; } this.setState(VoiceBroadcastPlaybackState.Stopped); } + public async start(): Promise { + if (this.queue.length === 0) { + await this.loadChunks(); + } + + if (this.queue.length === 0 || !this.queue[1]) { + // set to stopped fi the queue is empty of the first chunk (sequence number: 1-based index) is missing + this.setState(VoiceBroadcastPlaybackState.Stopped); + return; + } + + this.setState(VoiceBroadcastPlaybackState.Playing); + // index of the first schunk is the first sequence number + const first = this.queue[1]; + this.currentlyPlaying = first; + await first.play(); + } + + public get length(): number { + return this.chunkEvents.size; + } + + public stop(): void { + this.setState(VoiceBroadcastPlaybackState.Stopped); + + if (this.currentlyPlaying) { + this.currentlyPlaying.stop(); + } + } + + public pause(): void { + if (!this.currentlyPlaying) return; + + this.setState(VoiceBroadcastPlaybackState.Paused); + this.currentlyPlaying.pause(); + } + + public resume(): void { + if (!this.currentlyPlaying) return; + + this.setState(VoiceBroadcastPlaybackState.Playing); + this.currentlyPlaying.play(); + } + + /** + * Toggles the playback: + * stopped → playing + * playing → paused + * paused → playing + */ + public async toggle() { + if (this.state === VoiceBroadcastPlaybackState.Stopped) { + await this.start(); + return; + } + + if (this.state === VoiceBroadcastPlaybackState.Paused) { + this.resume(); + return; + } + + this.pause(); + } + public getState(): VoiceBroadcastPlaybackState { return this.state; } private setState(state: VoiceBroadcastPlaybackState): void { + if (this.state === state) { + return; + } + this.state = state; this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state); } - destroy(): void { + private destroyQueue(): void { + this.queue.forEach(p => p.destroy()); + this.queue = []; + } + + public destroy(): void { + if (this.relations) { + this.relations.off(RelationsEvent.Add, this.onRelationsEventAdd); + } + + this.infoEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); this.removeAllListeners(); + this.destroyQueue(); } } diff --git a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts index 1fdc8a9da5..38d774e088 100644 --- a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts +++ b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { VoiceBroadcastPlayback } from ".."; @@ -50,11 +50,11 @@ export class VoiceBroadcastPlaybacksStore extends TypedEventEmitter renders marker when beacon has location 1`] = ` "_eventsCount": 0, "_isOutOfBand": false, "_maxListeners": undefined, - "_modified": 1647270879403, - "_requestedProfileInfo": undefined, "disambiguate": false, - "events": Object { - "member": null, - }, - "membership": null, + "events": Object {}, + "membership": undefined, + "modified": 1647270879403, "name": "@alice:server", "powerLevel": 0, "powerLevelNorm": 0, "rawDisplayName": "@alice:server", + "requestedProfileInfo": false, "roomId": "!room:server", "typing": false, - "user": null, + "user": undefined, "userId": "@alice:server", Symbol(kCapture): false, } @@ -135,20 +133,18 @@ exports[` renders marker when beacon has location 1`] = ` "_eventsCount": 0, "_isOutOfBand": false, "_maxListeners": undefined, - "_modified": 1647270879403, - "_requestedProfileInfo": undefined, "disambiguate": false, - "events": Object { - "member": null, - }, - "membership": null, + "events": Object {}, + "membership": undefined, + "modified": 1647270879403, "name": "@alice:server", "powerLevel": 0, "powerLevelNorm": 0, "rawDisplayName": "@alice:server", + "requestedProfileInfo": false, "roomId": "!room:server", "typing": false, - "user": null, + "user": undefined, "userId": "@alice:server", Symbol(kCapture): false, } @@ -172,20 +168,18 @@ exports[` renders marker when beacon has location 1`] = ` "_eventsCount": 0, "_isOutOfBand": false, "_maxListeners": undefined, - "_modified": 1647270879403, - "_requestedProfileInfo": undefined, "disambiguate": false, - "events": Object { - "member": null, - }, - "membership": null, + "events": Object {}, + "membership": undefined, + "modified": 1647270879403, "name": "@alice:server", "powerLevel": 0, "powerLevelNorm": 0, "rawDisplayName": "@alice:server", + "requestedProfileInfo": false, "roomId": "!room:server", "typing": false, - "user": null, + "user": undefined, "userId": "@alice:server", Symbol(kCapture): false, } diff --git a/test/test-utils/audio.ts b/test/test-utils/audio.ts new file mode 100644 index 0000000000..012e353a98 --- /dev/null +++ b/test/test-utils/audio.ts @@ -0,0 +1,81 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 EventEmitter from "events"; +import { SimpleObservable } from "matrix-widget-api"; + +import { Playback, PlaybackState } from "../../src/audio/Playback"; +import { PlaybackClock } from "../../src/audio/PlaybackClock"; +import { UPDATE_EVENT } from "../../src/stores/AsyncStore"; + +type PublicInterface = { + [P in keyof T]: T[P]; +}; + +export const createTestPlayback = (): Playback => { + const eventEmitter = new EventEmitter(); + + return { + thumbnailWaveform: [1, 2, 3], + sizeBytes: 23, + waveform: [4, 5, 6], + waveformData: new SimpleObservable(), + destroy: jest.fn(), + play: jest.fn(), + prepare: jest.fn(), + pause: jest.fn(), + stop: jest.fn(), + toggle: jest.fn(), + skipTo: jest.fn(), + isPlaying: false, + clockInfo: createTestPlaybackClock(), + currentState: PlaybackState.Stopped, + emit: (event: PlaybackState, ...args: any[]): boolean => { + eventEmitter.emit(event, ...args); + eventEmitter.emit(UPDATE_EVENT, event, ...args); + return true; + }, + // EventEmitter + on: eventEmitter.on.bind(eventEmitter), + once: eventEmitter.once.bind(eventEmitter), + off: eventEmitter.off.bind(eventEmitter), + addListener: eventEmitter.addListener.bind(eventEmitter), + removeListener: eventEmitter.removeListener.bind(eventEmitter), + removeAllListeners: eventEmitter.removeAllListeners.bind(eventEmitter), + getMaxListeners: eventEmitter.getMaxListeners.bind(eventEmitter), + setMaxListeners: eventEmitter.setMaxListeners.bind(eventEmitter), + listeners: eventEmitter.listeners.bind(eventEmitter), + rawListeners: eventEmitter.rawListeners.bind(eventEmitter), + listenerCount: eventEmitter.listenerCount.bind(eventEmitter), + eventNames: eventEmitter.eventNames.bind(eventEmitter), + prependListener: eventEmitter.prependListener.bind(eventEmitter), + prependOnceListener: eventEmitter.prependOnceListener.bind(eventEmitter), + } as PublicInterface as Playback; +}; + +export const createTestPlaybackClock = (): PlaybackClock => { + return { + durationSeconds: 31, + timeSeconds: 41, + liveData: new SimpleObservable(), + populatePlaceholdersFrom: jest.fn(), + flagLoadTime: jest.fn(), + flagStart: jest.fn(), + flagStop: jest.fn(), + syncTo: jest.fn(), + destroy: jest.fn(), + } as PublicInterface as PlaybackClock; +}; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d04324c8ca..4549190600 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -184,6 +184,7 @@ export function createTestClient(): MatrixClient { setAudioInput: jest.fn(), } as unknown as MediaHandler), uploadContent: jest.fn(), + getEventMapper: () => (opts) => new MatrixEvent(opts), } as unknown as MatrixClient; client.reEmitter = new ReEmitter(client); diff --git a/test/voice-broadcast/components/VoiceBroadcastBody-test.tsx b/test/voice-broadcast/components/VoiceBroadcastBody-test.tsx index 84edf644f3..460437489b 100644 --- a/test/voice-broadcast/components/VoiceBroadcastBody-test.tsx +++ b/test/voice-broadcast/components/VoiceBroadcastBody-test.tsx @@ -79,7 +79,7 @@ describe("VoiceBroadcastBody", () => { client = stubClient(); infoEvent = mkVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started); testRecording = new VoiceBroadcastRecording(infoEvent, client); - testPlayback = new VoiceBroadcastPlayback(infoEvent); + testPlayback = new VoiceBroadcastPlayback(infoEvent, client); mocked(VoiceBroadcastRecordingBody).mockImplementation(({ recording }) => { if (testRecording === recording) { return
; diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx index 803fb57580..a4269533c3 100644 --- a/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx +++ b/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { render, RenderResult } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -23,7 +23,6 @@ import { VoiceBroadcastInfoEventType, VoiceBroadcastPlayback, VoiceBroadcastPlaybackBody, - VoiceBroadcastPlaybackState, } from "../../../../src/voice-broadcast"; import { mkEvent, stubClient } from "../../../test-utils"; @@ -38,11 +37,12 @@ jest.mock("../../../../src/components/views/avatars/RoomAvatar", () => ({ describe("VoiceBroadcastPlaybackBody", () => { const userId = "@user:example.com"; const roomId = "!room:example.com"; + let client: MatrixClient; let infoEvent: MatrixEvent; let playback: VoiceBroadcastPlayback; beforeAll(() => { - stubClient(); + client = stubClient(); infoEvent = mkEvent({ event: true, type: VoiceBroadcastInfoEventType, @@ -50,7 +50,8 @@ describe("VoiceBroadcastPlaybackBody", () => { room: roomId, user: userId, }); - playback = new VoiceBroadcastPlayback(infoEvent); + playback = new VoiceBroadcastPlayback(infoEvent, client); + jest.spyOn(playback, "toggle"); }); describe("when rendering a broadcast", () => { @@ -69,8 +70,8 @@ describe("VoiceBroadcastPlaybackBody", () => { await userEvent.click(renderResult.getByLabelText("resume voice broadcast")); }); - it("should stop the recording", () => { - expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Playing); + it("should toggle the recording", () => { + expect(playback.toggle).toHaveBeenCalled(); }); }); }); diff --git a/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts b/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts index e9a7b42101..6ae7517d35 100644 --- a/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts +++ b/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts @@ -15,22 +15,50 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixClient, MatrixEvent, MsgType, RelationType } from "matrix-js-sdk/src/matrix"; +import { Relations } from "matrix-js-sdk/src/models/relations"; +import { Playback, PlaybackState } from "../../../src/audio/Playback"; +import { PlaybackManager } from "../../../src/audio/PlaybackManager"; +import { getReferenceRelationsForEvent } from "../../../src/events"; +import { MediaEventHelper } from "../../../src/utils/MediaEventHelper"; import { + VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType, VoiceBroadcastPlayback, VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackState, } from "../../../src/voice-broadcast"; -import { mkEvent } from "../../test-utils"; +import { mkEvent, stubClient } from "../../test-utils"; +import { createTestPlayback } from "../../test-utils/audio"; + +jest.mock("../../../src/events/getReferenceRelationsForEvent", () => ({ + getReferenceRelationsForEvent: jest.fn(), +})); + +jest.mock("../../../src/utils/MediaEventHelper", () => ({ + MediaEventHelper: jest.fn(), +})); describe("VoiceBroadcastPlayback", () => { const userId = "@user:example.com"; const roomId = "!room:example.com"; + let client: MatrixClient; let infoEvent: MatrixEvent; let playback: VoiceBroadcastPlayback; let onStateChanged: (state: VoiceBroadcastPlaybackState) => void; + let chunk0Event: MatrixEvent; + let chunk1Event: MatrixEvent; + let chunk2Event: MatrixEvent; + const chunk0Data = new ArrayBuffer(1); + const chunk1Data = new ArrayBuffer(2); + const chunk2Data = new ArrayBuffer(3); + let chunk0Helper: MediaEventHelper; + let chunk1Helper: MediaEventHelper; + let chunk2Helper: MediaEventHelper; + let chunk0Playback: Playback; + let chunk1Playback: Playback; + let chunk2Playback: Playback; const itShouldSetTheStateTo = (state: VoiceBroadcastPlaybackState) => { it(`should set the state to ${state}`, () => { @@ -44,7 +72,36 @@ describe("VoiceBroadcastPlayback", () => { }); }; + const mkChunkEvent = (sequence: number) => { + return mkEvent({ + event: true, + user: client.getUserId(), + room: roomId, + type: EventType.RoomMessage, + content: { + msgtype: MsgType.Audio, + [VoiceBroadcastChunkEventType]: { + sequence, + }, + }, + }); + }; + + const mkChunkHelper = (data: ArrayBuffer): MediaEventHelper => { + return { + sourceBlob: { + cachedValue: null, + done: false, + value: { + // @ts-ignore + arrayBuffer: jest.fn().mockResolvedValue(data), + }, + }, + }; + }; + beforeAll(() => { + client = stubClient(); infoEvent = mkEvent({ event: true, type: VoiceBroadcastInfoEventType, @@ -52,65 +109,159 @@ describe("VoiceBroadcastPlayback", () => { room: roomId, content: {}, }); + + // crap event to test 0 as first sequence number + chunk0Event = mkChunkEvent(0); + chunk1Event = mkChunkEvent(1); + chunk2Event = mkChunkEvent(2); + + chunk0Helper = mkChunkHelper(chunk0Data); + chunk1Helper = mkChunkHelper(chunk1Data); + chunk2Helper = mkChunkHelper(chunk2Data); + + chunk0Playback = createTestPlayback(); + chunk1Playback = createTestPlayback(); + chunk2Playback = createTestPlayback(); + + jest.spyOn(PlaybackManager.instance, "createPlaybackInstance").mockImplementation( + (buffer: ArrayBuffer, _waveForm?: number[]) => { + if (buffer === chunk0Data) return chunk0Playback; + if (buffer === chunk1Data) return chunk1Playback; + if (buffer === chunk2Data) return chunk2Playback; + }, + ); + + mocked(MediaEventHelper).mockImplementation((event: MatrixEvent) => { + if (event === chunk0Event) return chunk0Helper; + if (event === chunk1Event) return chunk1Helper; + if (event === chunk2Event) return chunk2Helper; + }); }); beforeEach(() => { onStateChanged = jest.fn(); - playback = new VoiceBroadcastPlayback(infoEvent); + playback = new VoiceBroadcastPlayback(infoEvent, client); jest.spyOn(playback, "removeAllListeners"); playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged); }); - it("should expose the info event", () => { - expect(playback.infoEvent).toBe(infoEvent); - }); - - it("should be in state Stopped", () => { - expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped); - }); - - describe("when calling start", () => { + describe("when there is only a 0 sequence event", () => { beforeEach(() => { - playback.start(); + const relations = new Relations(RelationType.Reference, EventType.RoomMessage, client); + jest.spyOn(relations, "getRelations").mockReturnValue([chunk0Event]); + mocked(getReferenceRelationsForEvent).mockReturnValue(relations); }); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - - describe("and calling toggle", () => { - beforeEach(() => { - playback.toggle(); + describe("when calling start", () => { + beforeEach(async () => { + await playback.start(); }); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); - itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Stopped); }); }); - describe("when calling stop", () => { + describe("when there are some chunks", () => { beforeEach(() => { - playback.stop(); + const relations = new Relations(RelationType.Reference, EventType.RoomMessage, client); + jest.spyOn(relations, "getRelations").mockReturnValue([chunk2Event, chunk1Event]); + mocked(getReferenceRelationsForEvent).mockReturnValue(relations); }); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + it("should expose the info event", () => { + expect(playback.infoEvent).toBe(infoEvent); + }); - describe("and calling toggle", () => { - beforeEach(() => { - playback.toggle(); + it("should be in state Stopped", () => { + expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped); + }); + + describe("when calling start", () => { + beforeEach(async () => { + await playback.start(); }); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Stopped); - }); - }); - describe("when calling destroy", () => { - beforeEach(() => { - playback.destroy(); + it("should play the chunks", () => { + // assert that the first chunk is being played + expect(chunk1Playback.play).toHaveBeenCalled(); + expect(chunk2Playback.play).not.toHaveBeenCalled(); + + // simulate end of first chunk + chunk1Playback.emit(PlaybackState.Stopped); + + // assert that the second chunk is being played + expect(chunk2Playback.play).toHaveBeenCalled(); + + // simulate end of second chunk + chunk2Playback.emit(PlaybackState.Stopped); + + // assert that the entire playback is now in stopped state + expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped); + }); + + describe("and calling pause", () => { + beforeEach(() => { + playback.pause(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); + itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused); + }); }); - it("should call removeAllListeners", () => { - expect(playback.removeAllListeners).toHaveBeenCalled(); + describe("when calling toggle for the first time", () => { + beforeEach(async () => { + await playback.toggle(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); + + describe("and calling toggle a second time", () => { + beforeEach(async () => { + await playback.toggle(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); + + describe("and calling toggle a third time", () => { + beforeEach(async () => { + await playback.toggle(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); + }); + }); + }); + + describe("when calling stop", () => { + beforeEach(() => { + playback.stop(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + + describe("and calling toggle", () => { + beforeEach(async () => { + mocked(onStateChanged).mockReset(); + await playback.toggle(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); + itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Playing); + }); + }); + + describe("when calling destroy", () => { + beforeEach(() => { + playback.destroy(); + }); + + it("should call removeAllListeners", () => { + expect(playback.removeAllListeners).toHaveBeenCalled(); + }); }); }); }); diff --git a/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts b/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts index c028d2511a..8b994ef9c6 100644 --- a/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts +++ b/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts @@ -80,7 +80,7 @@ describe("VoiceBroadcastPlaybacksStore", () => { }); it("should return it by id", () => { - expect(playbacks.getByInfoEvent(infoEvent)).toBe(playback); + expect(playbacks.getByInfoEvent(infoEvent, client)).toBe(playback); }); it("should emit a CurrentChanged event", () => { @@ -105,7 +105,7 @@ describe("VoiceBroadcastPlaybacksStore", () => { describe("when retrieving a known playback", () => { beforeEach(() => { playbacks.setCurrent(playback); - returnedPlayback = playbacks.getByInfoEvent(infoEvent); + returnedPlayback = playbacks.getByInfoEvent(infoEvent, client); }); it("should return the playback", () => { @@ -115,7 +115,7 @@ describe("VoiceBroadcastPlaybacksStore", () => { describe("when retrieving an unknown playback", () => { beforeEach(() => { - returnedPlayback = playbacks.getByInfoEvent(infoEvent); + returnedPlayback = playbacks.getByInfoEvent(infoEvent, client); }); it("should return the playback", () => {