From 2ce5efb6d26fb6f945e8863328f2617325d301dd Mon Sep 17 00:00:00 2001 From: Your Name <172508132+sandstorm244@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:51:46 +0200 Subject: [PATCH] Tile Volume Persistence persist committed per-tile playback volumes in settings and restore them on tile creation --- src/settings/settings.test.ts | 36 ++++++++ src/settings/settings.ts | 25 ++++++ src/state/VolumeControls.test.ts | 85 +++++++++++++++++++ src/state/VolumeControls.ts | 85 ++++++++++++++----- src/state/media/RemoteScreenShareViewModel.ts | 7 ++ src/state/media/RemoteUserMediaViewModel.ts | 5 ++ src/utils/test.ts | 1 + 7 files changed, 221 insertions(+), 23 deletions(-) create mode 100644 src/state/VolumeControls.test.ts diff --git a/src/settings/settings.test.ts b/src/settings/settings.test.ts index 5280e58ea4..9fd445e9ae 100644 --- a/src/settings/settings.test.ts +++ b/src/settings/settings.test.ts @@ -10,7 +10,9 @@ import { describe, expect, it, beforeEach } from "vitest"; import { Setting, parseResolution, + saveTileVolume, seedSettingsFromConfig, + tileVolumes, screenShareCodec, screenShareResolution, screenShareFramerate, @@ -109,3 +111,37 @@ describe("seedSettingsFromConfig", () => { expect(screenShareFramerate.getValue()).toBe(defaultFramerate); }); }); + +describe("saveTileVolume", () => { + beforeEach(() => { + tileVolumes.setValue({}); + }); + + it("stores non-default volumes", () => { + saveTileVolume("@alice:example.org:DEVICE", 0.5); + expect(tileVolumes.getValue()).toEqual({ + "@alice:example.org:DEVICE": 0.5, + }); + }); + + it("removes the entry when set back to the default volume", () => { + saveTileVolume("@alice:example.org:DEVICE", 0.5); + saveTileVolume("@alice:example.org:DEVICE", 1); + expect(tileVolumes.getValue()).toEqual({}); + }); + + it("does not write when the volume is already at the default", () => { + const before = tileVolumes.getValue(); + saveTileVolume("@alice:example.org:DEVICE", 1); + expect(tileVolumes.getValue()).toBe(before); + }); + + it("keeps entries for other keys intact", () => { + saveTileVolume("@alice:example.org:DEVICE", 0.5); + saveTileVolume("@alice:example.org:DEVICE:screen-share", 1.2); + saveTileVolume("@alice:example.org:DEVICE", 1); + expect(tileVolumes.getValue()).toEqual({ + "@alice:example.org:DEVICE:screen-share": 1.2, + }); + }); +}); diff --git a/src/settings/settings.ts b/src/settings/settings.ts index d09a558381..cfed32413c 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -306,3 +306,28 @@ export function seedSettingsFromConfig( } } } + +/** + * Saved per-tile playback volumes, keyed by the participant's RTC backend + * identity (userId:deviceId), with a ":screen-share" suffix for screen share + * audio. Only non-default volumes are stored. + */ +export const tileVolumes = new Setting>( + "tile-volumes", + {}, +); + +/** + * Persist a tile's committed playback volume, dropping the entry when it is + * back at the default (1) so the map only holds actual adjustments. + */ +export function saveTileVolume(key: string, volume: number): void { + const volumes = { ...tileVolumes.getValue() }; + if (volume === 1) { + if (!(key in volumes)) return; + delete volumes[key]; + } else { + volumes[key] = volume; + } + tileVolumes.setValue(volumes); +} diff --git a/src/state/VolumeControls.test.ts b/src/state/VolumeControls.test.ts new file mode 100644 index 0000000000..cfe2bd459e --- /dev/null +++ b/src/state/VolumeControls.test.ts @@ -0,0 +1,85 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createVolumeControls } from "./VolumeControls"; +import { ObservableScope } from "./ObservableScope"; +import { constant } from "./Behavior"; + +describe("createVolumeControls", () => { + let scope: ObservableScope; + + beforeEach(() => { + scope = new ObservableScope(); + }); + + afterEach(() => scope.end()); + + function create(options?: { + initialVolume?: number; + onVolumeCommitted?: (volume: number) => void; + }): { + controls: ReturnType; + sink: ReturnType; + } { + const sink = vi.fn(); + const controls = createVolumeControls(scope, { + pretendToBeDisconnected$: constant(false), + sink$: constant(sink), + ...options, + }); + return { controls, sink }; + } + + it("starts at the initial volume and applies it to the sink", () => { + const { controls, sink } = create({ initialVolume: 0.4 }); + + expect(controls.playbackVolume$.value).toBe(0.4); + expect(sink).toHaveBeenCalledWith(0.4); + }); + + it("defaults to full volume without an initial volume", () => { + const { controls } = create(); + + expect(controls.playbackVolume$.value).toBe(1); + }); + + it("reports the volume on commit but not while adjusting", () => { + const onVolumeCommitted = vi.fn(); + const { controls } = create({ onVolumeCommitted }); + + controls.adjustPlaybackVolume(0.7); + expect(onVolumeCommitted).not.toHaveBeenCalled(); + + controls.commitPlaybackVolume(); + expect(onVolumeCommitted).toHaveBeenCalledTimes(1); + expect(onVolumeCommitted).toHaveBeenCalledWith(0.7); + }); + + it("does not report mute toggles or commits at zero", () => { + const onVolumeCommitted = vi.fn(); + const { controls } = create({ onVolumeCommitted }); + + controls.togglePlaybackMuted(); + expect(onVolumeCommitted).not.toHaveBeenCalled(); + + controls.adjustPlaybackVolume(0); + controls.commitPlaybackVolume(); + expect(onVolumeCommitted).not.toHaveBeenCalled(); + }); + + it("unmutes back to the initial volume", () => { + const { controls } = create({ initialVolume: 0.6 }); + + controls.togglePlaybackMuted(); + expect(controls.playbackVolume$.value).toBe(0); + + controls.togglePlaybackMuted(); + expect(controls.playbackVolume$.value).toBe(0.6); + }); +}); diff --git a/src/state/VolumeControls.ts b/src/state/VolumeControls.ts index beb7ae0068..67f5e2c5f6 100644 --- a/src/state/VolumeControls.ts +++ b/src/state/VolumeControls.ts @@ -5,7 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { combineLatest, map, merge, of, Subject, switchMap } from "rxjs"; +import { + combineLatest, + map, + merge, + of, + Subject, + switchMap, + withLatestFrom, +} from "rxjs"; import { type Behavior } from "./Behavior"; import { type ObservableScope } from "./ObservableScope"; @@ -35,6 +43,18 @@ interface VolumeControlsInputs { * requested volume. */ sink$: Behavior<(volume: number) => void>; + /** + * The volume to start at, e.g. restored from a saved preference. Defaults + * to 1. + */ + initialVolume?: number; + /** + * Called with the newly committed volume whenever the user finishes + * adjusting it (i.e. on commit, not while dragging). Not called for mute + * toggles or when the slider is released at zero, since those keep the + * previous committed volume. + */ + onVolumeCommitted?: (volume: number) => void; } /** @@ -43,7 +63,12 @@ interface VolumeControlsInputs { */ export function createVolumeControls( scope: ObservableScope, - { pretendToBeDisconnected$, sink$ }: VolumeControlsInputs, + { + pretendToBeDisconnected$, + sink$, + initialVolume = 1, + onVolumeCommitted, + }: VolumeControlsInputs, ): VolumeControls { const toggleMuted$ = new Subject<"toggle mute">(); const adjustVolume$ = new Subject(); @@ -51,31 +76,45 @@ export function createVolumeControls( const playbackVolume$ = scope.behavior( merge(toggleMuted$, adjustVolume$, commitVolume$).pipe( - accumulate({ volume: 1, committedVolume: 1 }, (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 }; - } - }), + 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 }; + } + }, + ), map(({ volume }) => volume), ), ); + // Notify the caller of newly committed volumes, e.g. so they can be + // persisted. Committing at zero keeps the previous committed volume (see + // above), so it is not reported. + if (onVolumeCommitted !== undefined) { + commitVolume$ + .pipe(withLatestFrom(playbackVolume$), scope.bind()) + .subscribe(([, volume]) => { + if (volume > 0) onVolumeCommitted(volume); + }); + } + // Sync the requested volume with the audio playback module combineLatest([ sink$, diff --git a/src/state/media/RemoteScreenShareViewModel.ts b/src/state/media/RemoteScreenShareViewModel.ts index 8c46aeb33b..cc3221cfa3 100644 --- a/src/state/media/RemoteScreenShareViewModel.ts +++ b/src/state/media/RemoteScreenShareViewModel.ts @@ -18,6 +18,7 @@ import { import { type ObservableScope } from "../ObservableScope"; import { createVolumeControls, type VolumeControls } from "../VolumeControls"; import { observeTrackReference$ } from "../observeTrackReference"; +import { saveTileVolume, tileVolumes } from "../../settings/settings"; export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel, VolumeControls { @@ -35,12 +36,16 @@ export interface RemoteScreenShareViewModel export interface RemoteScreenShareInputs extends BaseScreenShareInputs { participant$: Behavior; pretendToBeDisconnected$: Behavior; + rtcBackendIdentity: string; } export function createRemoteScreenShare( scope: ObservableScope, { pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs, ): RemoteScreenShareViewModel { + // Screen share audio gets its own saved volume, separate from the + // participant's voice volume. + const savedVolumeKey = `${inputs.rtcBackendIdentity}:screen-share`; return { ...createBaseScreenShare(scope, inputs), ...createVolumeControls(scope, { @@ -53,6 +58,8 @@ export function createRemoteScreenShare( ), ), ), + initialVolume: tileVolumes.getValue()[savedVolumeKey], + onVolumeCommitted: (volume) => saveTileVolume(savedVolumeKey, volume), }), local: false, videoEnabled$: scope.behavior( diff --git a/src/state/media/RemoteUserMediaViewModel.ts b/src/state/media/RemoteUserMediaViewModel.ts index 4307dea417..7d0ed9111d 100644 --- a/src/state/media/RemoteUserMediaViewModel.ts +++ b/src/state/media/RemoteUserMediaViewModel.ts @@ -11,6 +11,7 @@ import { combineLatest, map, of, switchMap } from "rxjs"; import { type Behavior } from "../Behavior"; import { createVolumeControls, type VolumeControls } from "../VolumeControls"; +import { saveTileVolume, tileVolumes } from "../../settings/settings"; import { type BaseUserMediaInputs, type BaseUserMediaViewModel, @@ -52,6 +53,10 @@ export function createRemoteUserMedia( sink$: scope.behavior( inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))), ), + // Restore and persist this participant device's saved volume + initialVolume: tileVolumes.getValue()[inputs.rtcBackendIdentity], + onVolumeCommitted: (volume) => + saveTileVolume(inputs.rtcBackendIdentity, volume), }), local: false, speaking$: scope.behavior( diff --git a/src/utils/test.ts b/src/utils/test.ts index 695dd04628..06eb2548e9 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -423,6 +423,7 @@ export function mockRemoteScreenShare( return createRemoteScreenShare(testScope(), { id: "screenshare", userId: member.userId, + rtcBackendIdentity: `${member.userId}:${rtcMember.deviceId}`, participant$: constant(participant), encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, livekitRoom$: constant(livekitRoom),