diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts index f8f213ada5..16ae9317e0 100644 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -56,7 +56,6 @@ export class VoiceBroadcastPlayback private state = VoiceBroadcastPlaybackState.Stopped; private infoState: VoiceBroadcastInfoState; private chunkEvents = new Map(); - /** Holds the playback queue with a 1-based index (sequence number) */ private queue: Playback[] = []; private currentlyPlaying: Playback; private lastInfoEvent: MatrixEvent; @@ -138,7 +137,7 @@ export class VoiceBroadcastPlayback private async enqueueChunk(chunkEvent: MatrixEvent) { const sequenceNumber = parseInt(chunkEvent.getContent()?.[VoiceBroadcastChunkEventType]?.sequence, 10); - if (isNaN(sequenceNumber)) return; + if (isNaN(sequenceNumber) || sequenceNumber < 1) return; const helper = new MediaEventHelper(chunkEvent); const blob = await helper.sourceBlob.value; @@ -146,7 +145,7 @@ export class VoiceBroadcastPlayback const playback = PlaybackManager.instance.createPlaybackInstance(buffer); await playback.prepare(); playback.clockInfo.populatePlaceholdersFrom(chunkEvent); - this.queue[sequenceNumber] = playback; + this.queue[sequenceNumber - 1] = playback; // -1 because the sequence number starts at 1 playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, state)); } @@ -171,17 +170,18 @@ export class VoiceBroadcastPlayback 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 + const toPlayIndex = this.getInfoState() === VoiceBroadcastInfoState.Stopped + ? 0 // start at the beginning for an ended voice broadcast + : this.queue.length - 1; // start at the current chunk for an ongoing voice broadcast + + if (this.queue.length === 0 || !this.queue[toPlayIndex]) { this.setState(VoiceBroadcastPlaybackState.Stopped); return; } this.setState(VoiceBroadcastPlaybackState.Playing); - // index of the first chunk is the first sequence number - const first = this.queue[1]; - this.currentlyPlaying = first; - await first.play(); + this.currentlyPlaying = this.queue[toPlayIndex]; + await this.currentlyPlaying.play(); } public get length(): number { diff --git a/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts b/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts index 6ae7517d35..15a3416226 100644 --- a/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts +++ b/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts @@ -25,6 +25,7 @@ import { MediaEventHelper } from "../../../src/utils/MediaEventHelper"; import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, VoiceBroadcastPlayback, VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackState, @@ -100,15 +101,33 @@ describe("VoiceBroadcastPlayback", () => { }; }; - beforeAll(() => { - client = stubClient(); - infoEvent = mkEvent({ + const mkInfoEvent = (state: VoiceBroadcastInfoState) => { + return mkEvent({ event: true, type: VoiceBroadcastInfoEventType, user: userId, room: roomId, - content: {}, + content: { + state, + }, }); + }; + + const mkPlayback = () => { + const playback = new VoiceBroadcastPlayback(infoEvent, client); + jest.spyOn(playback, "removeAllListeners"); + playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged); + return playback; + }; + + const setUpChunkEvents = (chunkEvents: MatrixEvent[]) => { + const relations = new Relations(RelationType.Reference, EventType.RoomMessage, client); + jest.spyOn(relations, "getRelations").mockReturnValue(chunkEvents); + mocked(getReferenceRelationsForEvent).mockReturnValue(relations); + }; + + beforeAll(() => { + client = stubClient(); // crap event to test 0 as first sequence number chunk0Event = mkChunkEvent(0); @@ -139,128 +158,148 @@ describe("VoiceBroadcastPlayback", () => { }); beforeEach(() => { + jest.clearAllMocks(); onStateChanged = jest.fn(); - - playback = new VoiceBroadcastPlayback(infoEvent, client); - jest.spyOn(playback, "removeAllListeners"); - playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged); }); - describe("when there is only a 0 sequence event", () => { + describe("when there is a running voice broadcast with some chunks", () => { beforeEach(() => { - const relations = new Relations(RelationType.Reference, EventType.RoomMessage, client); - jest.spyOn(relations, "getRelations").mockReturnValue([chunk0Event]); - mocked(getReferenceRelationsForEvent).mockReturnValue(relations); + infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Running); + playback = mkPlayback(); + setUpChunkEvents([chunk2Event, chunk0Event, chunk1Event]); }); - describe("when calling start", () => { + describe("and calling start", () => { beforeEach(async () => { await playback.start(); }); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); - }); - }); - - describe("when there are some chunks", () => { - beforeEach(() => { - const relations = new Relations(RelationType.Reference, EventType.RoomMessage, client); - jest.spyOn(relations, "getRelations").mockReturnValue([chunk2Event, chunk1Event]); - mocked(getReferenceRelationsForEvent).mockReturnValue(relations); - }); - - 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", () => { - beforeEach(async () => { - await playback.start(); - }); - - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - - it("should play the chunks", () => { + it("should play the last chunk", () => { // 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(); + expect(chunk1Playback.play).not.toHaveBeenCalled(); + }); + }); + }); - // simulate end of second chunk - chunk2Playback.emit(PlaybackState.Stopped); + describe("when there is a stopped voice broadcast", () => { + beforeEach(() => { + infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Stopped); + playback = mkPlayback(); + }); - // assert that the entire playback is now in stopped state + describe("and there is only a 0 sequence event", () => { + beforeEach(() => { + setUpChunkEvents([chunk0Event]); + }); + + describe("and calling start", () => { + beforeEach(async () => { + await playback.start(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + }); + }); + + describe("and there are some chunks", () => { + beforeEach(() => { + setUpChunkEvents([chunk2Event, chunk0Event, chunk1Event]); + }); + + it("should expose the info event", () => { + expect(playback.infoEvent).toBe(infoEvent); + }); + + it("should be in state Stopped", () => { expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped); }); - describe("and calling pause", () => { - beforeEach(() => { - playback.pause(); - }); - - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); - itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused); - }); - }); - - describe("when calling toggle for the first time", () => { - beforeEach(async () => { - await playback.toggle(); - }); - - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - - describe("and calling toggle a second time", () => { + describe("and calling start", () => { beforeEach(async () => { - await playback.toggle(); + await playback.start(); }); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - describe("and calling toggle a third time", () => { - beforeEach(async () => { - await playback.toggle(); + it("should play the chunks beginning with the first one", () => { + // 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.Playing); + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); + itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused); }); }); - }); - describe("when calling stop", () => { - beforeEach(() => { - playback.stop(); - }); - - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); - - describe("and calling toggle", () => { + describe("and calling toggle for the first time", () => { beforeEach(async () => { - mocked(onStateChanged).mockReset(); await playback.toggle(); }); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Playing); - }); - }); - describe("when calling destroy", () => { - beforeEach(() => { - playback.destroy(); + 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); + }); + }); }); - it("should call removeAllListeners", () => { - expect(playback.removeAllListeners).toHaveBeenCalled(); + describe("and 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("and calling destroy", () => { + beforeEach(() => { + playback.destroy(); + }); + + it("should call removeAllListeners", () => { + expect(playback.removeAllListeners).toHaveBeenCalled(); + }); }); }); });