Files
element-web/src/audio/PlaybackQueue.ts
T
David Langley ae2acdf311 Use context provided RoomViewStore within the RoomView component hierarchy (#31077)
* Update ContentMessages.ts

Update ContentMessages.ts

* update PlaybackQueue.ts

* Update SpaceHierarchy.tsx

* Update ThreadView.tsx

* Update RoomCallBanner.tsx

* Update useRoomCall.tsx

* Update DateSeparator.tsx

* Update TimelineCard.tsx

* Update UserInfoBasicOptions

* Update slask-commands/utils.ts

* lint

* Update PlaybackQueue, MVoiceMessageBody and UserInfoBasicOptionsView tests.

* Update RoomHeader-test.tsx

* lint

* Add ts docs

* Update utils-test.tsx

* Update message-test.ts

* coverage

* lint

* Improve naming

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-29 09:40:21 +00:00

231 lines
11 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { EventType, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type Playback, PlaybackState } from "./Playback";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { arrayFastClone } from "../utils/arrays";
import { PlaybackManager } from "./PlaybackManager";
import { isVoiceMessage } from "../utils/EventUtils";
import { type RoomViewStore } from "../stores/RoomViewStore";
/**
* Audio playback queue management for a given room. This keeps track of where the user
* was at for each playback, what order the playbacks were played in, and triggers subsequent
* playbacks.
*
* Currently this is only intended to be used by voice messages.
*
* The primary mechanics are:
* * Persisted clock state for each playback instance (tied to Event ID).
* * Limited memory of playback order (see code; not persisted).
* * Autoplay of next eligible playback instance.
*/
export class PlaybackQueue {
private static queues = new Map<string, PlaybackQueue>(); // keyed by room ID
private playbacks = new Map<string, Playback>(); // keyed by event ID
private clockStates = new Map<string, number>(); // keyed by event ID
private playbackIdOrder: string[] = []; // event IDs, last == current
private currentPlaybackId: string | null = null; // event ID, broken out from above for ease of use
private recentFullPlays = new Set<string>(); // event IDs
/**
* Create a PlaybackQueue for a given room.
* @param room The room
* @param roomViewStore The RoomViewStore instance
*/
public constructor(
private room: Room,
private roomViewStore: RoomViewStore,
) {
this.loadClocks();
this.roomViewStore.addRoomListener(this.room.roomId, (isActive) => {
if (!isActive) return;
// Reset the state of the playbacks before they start mounting and enqueuing updates.
// We reset the entirety of the queue, including order, to ensure the user isn't left
// confused with what order the messages are playing in.
this.currentPlaybackId = null; // this in particular stops autoplay when the room is switched to
this.recentFullPlays = new Set<string>();
this.playbackIdOrder = [];
});
}
/**
* Get the PlaybackQueue for a given room, creating it if necessary.
* @param roomId The ID of the room
* @param roomViewStore The RoomViewStore instance
* @returns The PlaybackQueue for the room
*/
public static forRoom(roomId: string, roomViewStore: RoomViewStore): PlaybackQueue {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(roomId);
if (!room) throw new Error("Unknown room");
if (PlaybackQueue.queues.has(room.roomId)) {
return PlaybackQueue.queues.get(room.roomId)!;
}
const queue = new PlaybackQueue(room, roomViewStore);
PlaybackQueue.queues.set(room.roomId, queue);
return queue;
}
private persistClocks(): void {
localStorage.setItem(
`mx_voice_message_clocks_${this.room.roomId}`,
JSON.stringify(Array.from(this.clockStates.entries())),
);
}
private loadClocks(): void {
const val = localStorage.getItem(`mx_voice_message_clocks_${this.room.roomId}`);
if (!!val) {
this.clockStates = new Map<string, number>(JSON.parse(val));
// Clean out any null values (from older versions)
for (const [key, value] of this.clockStates.entries()) {
if (value == null) {
this.clockStates.delete(key);
}
}
}
}
public unsortedEnqueue(mxEvent: MatrixEvent, playback: Playback): void {
// We don't ever detach our listeners: we expect the Playback to clean up for us
this.playbacks.set(mxEvent.getId()!, playback);
playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, mxEvent, state));
playback.clockInfo.liveData.onUpdate((clock) => this.onPlaybackClock(playback, mxEvent, clock));
}
private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState): void {
// Remember where the user got to in playback
const wasLastPlaying = this.currentPlaybackId === mxEvent.getId();
const currentClockState = this.clockStates.get(mxEvent.getId()!);
if (newState === PlaybackState.Stopped && currentClockState !== undefined && !wasLastPlaying) {
// noinspection JSIgnoredPromiseFromCall
playback.skipTo(currentClockState);
} else if (newState === PlaybackState.Stopped) {
// Remove the now-useless clock for some space savings
this.clockStates.delete(mxEvent.getId()!);
if (wasLastPlaying && this.currentPlaybackId) {
this.recentFullPlays.add(this.currentPlaybackId);
const orderClone = arrayFastClone(this.playbackIdOrder);
const last = orderClone.pop();
if (last === this.currentPlaybackId) {
const next = orderClone.pop();
if (next) {
const instance = this.playbacks.get(next);
if (!instance) {
logger.warn(
"Voice message queue desync: Missing playback for next message: " +
`Current=${this.currentPlaybackId} Last=${last} Next=${next}`,
);
} else {
this.playbackIdOrder = orderClone;
PlaybackManager.instance.pauseAllExcept(instance);
// This should cause a Play event, which will re-populate our playback order
// and update our current playback ID.
// noinspection JSIgnoredPromiseFromCall
instance.play();
}
} else {
// else no explicit next event, so find an event we haven't played that comes next. The live
// timeline is already most recent last, so we can iterate down that.
const timeline = arrayFastClone(this.room.getLiveTimeline().getEvents());
let scanForVoiceMessage = false;
let nextEv: MatrixEvent | undefined;
for (const event of timeline) {
if (event.getId() === mxEvent.getId()) {
scanForVoiceMessage = true;
continue;
}
if (!scanForVoiceMessage) continue;
if (!isVoiceMessage(event)) {
const evType = event.getType();
if (evType !== EventType.RoomMessage && evType !== EventType.Sticker) {
continue; // Event can be skipped for automatic playback consideration
}
break; // Stop automatic playback: next useful event is not a voice message
}
const havePlayback = this.playbacks.has(event.getId()!);
const isRecentlyCompleted = this.recentFullPlays.has(event.getId()!);
if (havePlayback && !isRecentlyCompleted) {
nextEv = event;
break;
}
}
if (!nextEv) {
// if we don't have anywhere to go, reset the recent playback queue so the user
// can start a new chain of playbacks.
this.recentFullPlays = new Set<string>();
this.playbackIdOrder = [];
} else {
this.playbackIdOrder = orderClone;
const instance = this.playbacks.get(nextEv.getId()!);
PlaybackManager.instance.pauseAllExcept(instance);
// This should cause a Play event, which will re-populate our playback order
// and update our current playback ID.
// noinspection JSIgnoredPromiseFromCall
instance?.play();
}
}
} else {
logger.warn(
"Voice message queue desync: Expected playback stop to be last in order. " +
`Current=${this.currentPlaybackId} Last=${last} EventID=${mxEvent.getId()}`,
);
}
}
}
if (newState === PlaybackState.Playing) {
const order = this.playbackIdOrder;
if (this.currentPlaybackId !== mxEvent.getId() && !!this.currentPlaybackId) {
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
const lastInstance = this.playbacks.get(this.currentPlaybackId);
if (
lastInstance &&
[PlaybackState.Playing, PlaybackState.Paused].includes(lastInstance.currentState)
) {
order.push(this.currentPlaybackId);
}
}
}
this.currentPlaybackId = mxEvent.getId()!;
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
order.push(this.currentPlaybackId);
}
}
// Only persist clock information on pause/stop (end) to avoid overwhelming the storage.
// This should get triggered from normal voice message component unmount due to the playback
// stopping itself for cleanup.
if (newState === PlaybackState.Paused || newState === PlaybackState.Stopped) {
this.persistClocks();
}
}
private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]): void {
if (playback.currentState !== PlaybackState.Playing && playback.currentState !== PlaybackState.Paused) return; // ignore pre-ready values
this.clockStates.set(mxEvent.getId()!, clocks[0]); // [0] is the current seek position
}
}