/* Copyright 2023-2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { type IWidgetApiRequest } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/lib/logger"; import { combineLatest, distinctUntilChanged, fromEvent, map, merge, type Observable, of, Subject, switchMap, withLatestFrom, } from "rxjs"; import { type MediaDevices, type MediaDevice } from "../state/MediaDevices"; import { ElementWidgetActions, widget } from "../widget"; import { Config } from "../config/Config"; import { getUrlParams } from "../UrlParams"; import { type ObservableScope } from "./ObservableScope"; import { accumulate } from "../utils/observable"; interface MuteStateData { enabled$: Observable; set: ((enabled: boolean) => void) | null; toggle: (() => void) | null; } class MuteState { private readonly enabledByDefault$ = this.enabledByConfig && !getUrlParams().skipLobby ? this.isJoined$.pipe(map((isJoined) => !isJoined)) : of(false); private readonly data$: Observable = this.device.available$.pipe( map((available) => available.size > 0), distinctUntilChanged(), withLatestFrom( this.enabledByDefault$, (devicesConnected, enabledByDefault) => { if (!devicesConnected) return { enabled$: of(false), set: null, toggle: null }; const set$ = new Subject(); const toggle$ = new Subject(); return { set: (enabled: boolean) => set$.next(enabled), toggle: () => toggle$.next(), // Assume the default value only once devices are actually connected enabled$: merge( set$, toggle$.pipe(map(() => "toggle" as const)), ).pipe( accumulate(enabledByDefault, (prev, update) => update === "toggle" ? !prev : update, ), ), }; }, ), this.scope.state(), ); public readonly enabled$: Observable = this.data$.pipe( switchMap(({ enabled$ }) => enabled$), ); public readonly setEnabled$: Observable<((enabled: boolean) => void) | null> = this.data$.pipe(map(({ set }) => set)); public readonly toggle$: Observable<(() => void) | null> = this.data$.pipe( map(({ toggle }) => toggle), ); public constructor( private readonly scope: ObservableScope, private readonly device: MediaDevice, private readonly isJoined$: Observable, private readonly enabledByConfig: boolean, ) {} } export class MuteStates { public readonly audio = new MuteState( this.scope, this.mediaDevices.audioInput, this.isJoined$, Config.get().media_devices.enable_video, ); public readonly video = new MuteState( this.scope, this.mediaDevices.videoInput, this.isJoined$, Config.get().media_devices.enable_video, ); public constructor( private readonly scope: ObservableScope, private readonly mediaDevices: MediaDevices, private readonly isJoined$: Observable, ) { if (widget !== null) { // Sync our mute states with the hosting client const widgetApiState$ = combineLatest( [this.audio.enabled$, this.video.enabled$], (audio, video) => ({ audio_enabled: audio, video_enabled: video }), ); widgetApiState$.pipe(this.scope.bind()).subscribe((state) => { widget!.api.transport .send(ElementWidgetActions.DeviceMute, state) .catch((e) => logger.warn("Could not send DeviceMute action to widget", e), ); }); // Also sync the hosting client's mute states back with ours const muteActions$ = fromEvent( widget.lazyActions, ElementWidgetActions.DeviceMute, ) as Observable>; muteActions$ .pipe( withLatestFrom( widgetApiState$, this.audio.setEnabled$, this.video.setEnabled$, ), this.scope.bind(), ) .subscribe(([ev, state, setAudioEnabled, setVideoEnabled]) => { // First copy the current state into our new state const newState = { ...state }; // Update new state if there are any requested changes from the widget // action in `ev.detail.data`. if ( ev.detail.data.audio_enabled != null && typeof ev.detail.data.audio_enabled === "boolean" && setAudioEnabled !== null ) { newState.audio_enabled = ev.detail.data.audio_enabled; setAudioEnabled(newState.audio_enabled); } if ( ev.detail.data.video_enabled != null && typeof ev.detail.data.video_enabled === "boolean" && setVideoEnabled !== null ) { newState.video_enabled = ev.detail.data.video_enabled; setVideoEnabled(newState.video_enabled); } widget!.api.transport.reply(ev.detail, newState); }); } } }