UNPKG

matrix-react-sdk

Version:
442 lines (374 loc) 15.2 kB
/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; import { ClientEvent, ClientEventHandlerMap, EventType, MatrixClient, MatrixEvent, MatrixEventEvent, MsgType, RelationType, TypedEventEmitter, } from "matrix-js-sdk/src/matrix"; import { AudioContent, EncryptedFile } from "matrix-js-sdk/src/types"; import { ChunkRecordedPayload, createVoiceBroadcastRecorder, getMaxBroadcastLength, VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState, VoiceBroadcastRecorder, VoiceBroadcastRecorderEvent, } from ".."; import { uploadFile } from "../../ContentMessages"; import { createVoiceMessageContent } from "../../utils/createVoiceMessageContent"; import { IDestroyable } from "../../utils/IDestroyable"; import dis from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; import { createReconnectedListener } from "../../utils/connection"; import { localNotificationsAreSilenced } from "../../utils/notifications"; import { BackgroundAudio } from "../../audio/BackgroundAudio"; export enum VoiceBroadcastRecordingEvent { StateChanged = "liveness_changed", TimeLeftChanged = "time_left_changed", } export type VoiceBroadcastRecordingState = VoiceBroadcastInfoState | "connection_error"; interface EventMap { [VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastRecordingState) => void; [VoiceBroadcastRecordingEvent.TimeLeftChanged]: (timeLeft: number) => void; } export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap> implements IDestroyable { private state: VoiceBroadcastRecordingState; private recorder: VoiceBroadcastRecorder | null = null; private dispatcherRef: string; private chunkEvents = new VoiceBroadcastChunkEvents(); private chunkRelationHelper: RelationsHelper; private maxLength: number; private timeLeft: number; private toRetry: Array<() => Promise<void>> = []; private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync]; private roomId: string; private infoEventId: string; private backgroundAudio = new BackgroundAudio(); /** * Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing. * This variable holds the last sequence number. * Starts with 0 because there is no chunk at the beginning of a broadcast. * Will be incremented when a chunk message is created. */ private sequence = 0; public constructor( public readonly infoEvent: MatrixEvent, private client: MatrixClient, initialState?: VoiceBroadcastInfoState, ) { super(); this.maxLength = getMaxBroadcastLength(); this.timeLeft = this.maxLength; this.infoEventId = this.determineEventIdFromInfoEvent(); this.roomId = this.determineRoomIdFromInfoEvent(); if (initialState) { this.state = initialState; } else { this.state = this.determineInitialStateFromInfoEvent(); } // TODO Michael W: listen for state updates this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.dispatcherRef = dis.register(this.onAction); this.chunkRelationHelper = this.initialiseChunkEventRelation(); this.reconnectedListener = createReconnectedListener(this.onReconnect); this.client.on(ClientEvent.Sync, this.reconnectedListener); } private initialiseChunkEventRelation(): RelationsHelper { const relationsHelper = new RelationsHelper( this.infoEvent, RelationType.Reference, EventType.RoomMessage, this.client, ); relationsHelper.on(RelationsHelperEvent.Add, this.onChunkEvent); relationsHelper.emitFetchCurrent().catch((err) => { logger.warn("error fetching server side relation for voice broadcast chunks", err); // fall back to local events relationsHelper.emitCurrent(); }); return relationsHelper; } private onChunkEvent = (event: MatrixEvent): void => { if ( (!event.getId() && !event.getTxnId()) || event.getContent()?.msgtype !== MsgType.Audio // don't add non-audio event ) { return; } this.chunkEvents.addEvent(event); }; private determineEventIdFromInfoEvent(): string { const infoEventId = this.infoEvent.getId(); if (!infoEventId) { throw new Error("Cannot create broadcast for info event without Id."); } return infoEventId; } private determineRoomIdFromInfoEvent(): string { const roomId = this.infoEvent.getRoomId(); if (!roomId) { throw new Error(`Cannot create broadcast for unknown room (info event ${this.infoEventId})`); } return roomId; } /** * Determines the initial broadcast state. * Checks all related events. If one has the "stopped" state → stopped, else started. */ private determineInitialStateFromInfoEvent(): VoiceBroadcastRecordingState { const room = this.client.getRoom(this.roomId); const relations = room ?.getUnfilteredTimelineSet() ?.relations?.getChildEventsForEvent(this.infoEventId, RelationType.Reference, VoiceBroadcastInfoEventType); const relatedEvents = relations?.getRelations(); return !relatedEvents?.find((event: MatrixEvent) => { return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; }) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped; } public getTimeLeft(): number { return this.timeLeft; } /** * Retries failed actions on reconnect. */ private onReconnect = async (): Promise<void> => { // Do nothing if not in connection_error state. if (this.state !== "connection_error") return; // Copy the array, so that it is possible to remove elements from it while iterating over the original. const toRetryCopy = [...this.toRetry]; for (const retryFn of this.toRetry) { try { await retryFn(); // Successfully retried. Remove from array copy. toRetryCopy.splice(toRetryCopy.indexOf(retryFn), 1); } catch { // The current retry callback failed. Stop the loop. break; } } this.toRetry = toRetryCopy; if (this.toRetry.length === 0) { // Everything has been successfully retried. Recover from error state to paused. await this.pause(); } }; private async setTimeLeft(timeLeft: number): Promise<void> { if (timeLeft <= 0) { // time is up - stop the recording return await this.stop(); } // do never increase time left; no action if equals if (timeLeft >= this.timeLeft) return; this.timeLeft = timeLeft; this.emit(VoiceBroadcastRecordingEvent.TimeLeftChanged, timeLeft); } public async start(): Promise<void> { return this.getRecorder().start(); } public async stop(): Promise<void> { if (this.state === VoiceBroadcastInfoState.Stopped) return; this.setState(VoiceBroadcastInfoState.Stopped); await this.stopRecorder(); await this.sendInfoStateEvent(VoiceBroadcastInfoState.Stopped); } public async pause(): Promise<void> { // stopped or already paused recordings cannot be paused if ( ( [VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Paused] as VoiceBroadcastRecordingState[] ).includes(this.state) ) return; this.setState(VoiceBroadcastInfoState.Paused); await this.stopRecorder(); await this.sendInfoStateEvent(VoiceBroadcastInfoState.Paused); } public async resume(): Promise<void> { if (this.state !== VoiceBroadcastInfoState.Paused) return; this.setState(VoiceBroadcastInfoState.Resumed); await this.getRecorder().start(); await this.sendInfoStateEvent(VoiceBroadcastInfoState.Resumed); } public toggle = async (): Promise<void> => { if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume(); if ( ( [VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed] as VoiceBroadcastRecordingState[] ).includes(this.getState()) ) { return this.pause(); } }; public getState(): VoiceBroadcastRecordingState { return this.state; } private getRecorder(): VoiceBroadcastRecorder { if (!this.recorder) { this.recorder = createVoiceBroadcastRecorder(); this.recorder.on(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded); this.recorder.on(VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated, this.onCurrentChunkLengthUpdated); } return this.recorder; } public async destroy(): Promise<void> { if (this.recorder) { this.recorder.stop(); this.recorder.destroy(); } this.infoEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.removeAllListeners(); dis.unregister(this.dispatcherRef); this.chunkEvents = new VoiceBroadcastChunkEvents(); this.chunkRelationHelper.destroy(); this.client.off(ClientEvent.Sync, this.reconnectedListener); } private onBeforeRedaction = (): void => { if (this.getState() !== VoiceBroadcastInfoState.Stopped) { this.setState(VoiceBroadcastInfoState.Stopped); // destroy cleans up everything this.destroy(); } }; private onAction = (payload: ActionPayload): void => { if (payload.action !== "call_state") return; // pause on any call action this.pause(); }; private setState(state: VoiceBroadcastRecordingState): void { this.state = state; this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state); } private onCurrentChunkLengthUpdated = (currentChunkLength: number): void => { this.setTimeLeft(this.maxLength - this.chunkEvents.getLengthSeconds() - currentChunkLength); }; private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise<void> => { const uploadAndSendFn = async (): Promise<void> => { const { url, file } = await this.uploadFile(chunk); await this.sendVoiceMessage(chunk, url, file); }; await this.callWithRetry(uploadAndSendFn); }; /** * This function is called on connection errors. * It sets the connection error state and stops the recorder. */ private async onConnectionError(): Promise<void> { this.playConnectionErrorAudioNotification().catch(() => { // Error logged in playConnectionErrorAudioNotification(). }); await this.stopRecorder(false); this.setState("connection_error"); } private async playConnectionErrorAudioNotification(): Promise<void> { if (localNotificationsAreSilenced(this.client)) { return; } await this.backgroundAudio.pickFormatAndPlay("./media/error", ["mp3", "ogg"]); } private async uploadFile(chunk: ChunkRecordedPayload): ReturnType<typeof uploadFile> { return uploadFile( this.client, this.roomId, new Blob([chunk.buffer], { type: this.getRecorder().contentType, }), ); } private async sendVoiceMessage(chunk: ChunkRecordedPayload, url?: string, file?: EncryptedFile): Promise<void> { /** * Increment the last sequence number and use it for this message. * Done outside of the sendMessageFn to get a scoped value. * Also see {@link VoiceBroadcastRecording.sequence}. */ const sequence = ++this.sequence; const sendMessageFn = async (): Promise<void> => { const content = createVoiceMessageContent( url, this.getRecorder().contentType, Math.round(chunk.length * 1000), chunk.buffer.length, file, ); content["m.relates_to"] = { rel_type: RelationType.Reference, event_id: this.infoEventId, }; (<AudioContent>content)["io.element.voice_broadcast_chunk"] = { sequence, }; await this.client.sendMessage(this.roomId, content); }; await this.callWithRetry(sendMessageFn); } /** * Sends an info state event with given state. * On error stores a resend function and setState(state) in {@link toRetry} and * sets the broadcast state to connection_error. */ private async sendInfoStateEvent(state: VoiceBroadcastInfoState): Promise<void> { const sendEventFn = async (): Promise<void> => { await this.client.sendStateEvent( this.roomId, VoiceBroadcastInfoEventType, { device_id: this.client.getDeviceId(), state, last_chunk_sequence: this.sequence, ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: this.infoEventId, }, } as VoiceBroadcastInfoEventContent, this.client.getSafeUserId(), ); }; await this.callWithRetry(sendEventFn); } /** * Calls the function. * On failure adds it to the retry list and triggers connection error. * {@link toRetry} * {@link onConnectionError} */ private async callWithRetry(retryAbleFn: () => Promise<void>): Promise<void> { try { await retryAbleFn(); } catch { this.toRetry.push(retryAbleFn); this.onConnectionError(); } } private async stopRecorder(emit = true): Promise<void> { if (!this.recorder) { return; } try { const lastChunk = await this.recorder.stop(); if (lastChunk && emit) { await this.onChunkRecorded(lastChunk); } } catch (err) { logger.warn("error stopping voice broadcast recorder", err); } } }