/* Copyright 2026 Element Software Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { combineLatest, distinctUntilChanged, map, merge, of, skip, Subject, switchMap, } from "rxjs"; import { type Behavior } from "./Behavior"; import { type ObservableScope } from "./ObservableScope"; import { accumulate } from "../utils/observable"; /** * Controls for audio playback volume. */ export interface VolumeControls { /** * The volume to which the audio is set, as a scalar multiplier. */ playbackVolume$: Behavior; /** * Whether playback of this audio is disabled. */ playbackMuted$: Behavior; togglePlaybackMuted: () => void; adjustPlaybackVolume: (value: number) => void; /** * Commit the volume. [lotus #36] Pass the slider's committed value: with the * keyboard the slider commits before its last change reaches us, which left * the committed (and remembered) volume one step behind. */ commitPlaybackVolume: (value?: number) => void; } interface VolumeControlsInputs { pretendToBeDisconnected$: Behavior; /** * The callback to run to notify the module performing audio playback of the * requested volume. */ sink$: Behavior<(volume: number) => void>; /** [lotus #36] Starting volume (a remembered one); defaults to 1. */ initialVolume?: number; /** [lotus #36] Called with each newly committed volume. */ onCommit?: (volume: number) => void; } /** * Creates a set of controls for audio playback volume and syncs this with the * audio playback module for the duration of the scope. */ export function createVolumeControls( scope: ObservableScope, { pretendToBeDisconnected$, sink$, initialVolume = 1, onCommit, }: VolumeControlsInputs, ): VolumeControls { const toggleMuted$ = new Subject<"toggle mute">(); const adjustVolume$ = new Subject(); const commitVolume$ = new Subject<"commit">(); const state$ = scope.behavior( merge(toggleMuted$, adjustVolume$, commitVolume$).pipe( accumulate( { volume: initialVolume, committedVolume: initialVolume }, (state, event) => { switch (event) { case "toggle mute": return { ...state, volume: state.volume === 0 ? state.committedVolume : 0, }; case "commit": // Dragging the slider to zero should have the same effect as // muting: keep the original committed volume, as if it were never // dragged return { ...state, committedVolume: state.volume === 0 ? state.committedVolume : state.volume, }; default: // Volume adjustment return { ...state, volume: event }; } }, ), ), ); const playbackVolume$ = scope.behavior( state$.pipe(map(({ volume }) => volume)), ); // [lotus #36] Report committed changes (not the starting value) so the // caller can remember them. if (onCommit) { state$ .pipe( map(({ committedVolume }) => committedVolume), distinctUntilChanged(), skip(1), scope.bind(), ) .subscribe(onCommit); } // Sync the requested volume with the audio playback module combineLatest([ sink$, // The playback volume, taking into account whether we're supposed to // pretend that the audio stream is disconnected (since we don't necessarily // want that to modify the UI state). pretendToBeDisconnected$.pipe( switchMap((disconnected) => (disconnected ? of(0) : playbackVolume$)), ), ]) .pipe(scope.bind()) .subscribe(([sink, volume]) => sink(volume)); return { playbackVolume$, playbackMuted$: scope.behavior( playbackVolume$.pipe(map((volume) => volume === 0)), ), togglePlaybackMuted: () => toggleMuted$.next("toggle mute"), adjustPlaybackVolume: (value: number) => adjustVolume$.next(value), commitPlaybackVolume: (value?: number) => { if (value !== undefined) adjustVolume$.next(value); commitVolume$.next("commit"); }, }; }