diff --git a/src/lotus/lotusVolumeMemory.test.ts b/src/lotus/lotusVolumeMemory.test.ts new file mode 100644 index 00000000..3cbf2105 --- /dev/null +++ b/src/lotus/lotusVolumeMemory.test.ts @@ -0,0 +1,60 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { beforeEach, describe, expect, it } from "vitest"; + +import { + MAX_REMEMBERED, + getRememberedVolume, + rememberVolume, +} from "./lotusVolumeMemory"; + +describe("lotusVolumeMemory", () => { + beforeEach(() => localStorage.clear()); + + it("defaults to 1 and remembers a set volume", () => { + expect(getRememberedVolume("@bob:x")).toBe(1); + rememberVolume("@bob:x", 0.4); + expect(getRememberedVolume("@bob:x")).toBe(0.4); + }); + + it("forgets the entry when set back to 1", () => { + rememberVolume("@bob:x", 0.4); + rememberVolume("@bob:x", 1); + expect(getRememberedVolume("@bob:x")).toBe(1); + expect(localStorage.getItem("lotus-per-user-volume")).toBe("{}"); + }); + + it("keeps only the most recently set entries", () => { + for (let i = 0; i < MAX_REMEMBERED + 5; i++) rememberVolume(`@u${i}:x`, 0.5); + expect(getRememberedVolume("@u0:x")).toBe(1); + expect(getRememberedVolume("@u4:x")).toBe(1); + expect(getRememberedVolume("@u5:x")).toBe(0.5); + expect(getRememberedVolume(`@u${MAX_REMEMBERED + 4}:x`)).toBe(0.5); + }); + + it("re-setting an old entry makes it recent", () => { + for (let i = 0; i < MAX_REMEMBERED; i++) rememberVolume(`@u${i}:x`, 0.5); + rememberVolume("@u0:x", 0.7); + rememberVolume("@new:x", 0.3); + expect(getRememberedVolume("@u0:x")).toBe(0.7); + expect(getRememberedVolume("@u1:x")).toBe(1); + }); + + it("ignores junk in storage and invalid volumes", () => { + localStorage.setItem( + "lotus-per-user-volume", + JSON.stringify({ "@a:x": "loud", "@b:x": 0.2 }), + ); + expect(getRememberedVolume("@a:x")).toBe(1); + expect(getRememberedVolume("@b:x")).toBe(0.2); + rememberVolume("@c:x", Number.NaN); + expect(getRememberedVolume("@c:x")).toBe(1); + localStorage.setItem("lotus-per-user-volume", "not json"); + expect(getRememberedVolume("@b:x")).toBe(1); + }); +}); diff --git a/src/lotus/lotusVolumeMemory.ts b/src/lotus/lotusVolumeMemory.ts new file mode 100644 index 00000000..9d89c43f --- /dev/null +++ b/src/lotus/lotusVolumeMemory.ts @@ -0,0 +1,65 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/** + * [element-call #36] Remember the per-participant volume slider across calls, + * reloads and reconnects. Keyed by Matrix user id (not device), so "Bob is + * loud" sticks when Bob switches devices. Local to this browser, never synced. + * Only voice is remembered; screenshare audio stays per-share. + */ + +const STORAGE_KEY = "lotus-per-user-volume"; +/** Most recently set entries kept; older ones are dropped. */ +export const MAX_REMEMBERED = 50; + +type VolumeMap = Record; + +const isVolume = (v: unknown): v is number => + typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 4; + +function load(): VolumeMap { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + const out: VolumeMap = {}; + for (const [k, v] of Object.entries(parsed)) if (isVolume(v)) out[k] = v; + return out; + } catch { + return {}; + } +} + +function save(map: VolumeMap): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + // Storage unavailable (private mode, quota): the slider just won't stick. + } +} + +/** The remembered volume for `userId`, or 1 (100 %) if none. */ +export function getRememberedVolume(userId: string): number { + return load()[userId] ?? 1; +} + +/** + * Remember `volume` for `userId`. 1 (the default) forgets the entry. The entry + * moves to the end so the map is in least-recently-set order, trimmed to + * MAX_REMEMBERED. + */ +export function rememberVolume(userId: string, volume: number): void { + if (!isVolume(volume)) return; + const map = load(); + delete map[userId]; + if (volume !== 1) map[userId] = volume; + const keys = Object.keys(map); + for (const k of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) + delete map[k]; + save(map); +} diff --git a/src/state/VolumeControls.ts b/src/state/VolumeControls.ts index beb7ae00..19911686 100644 --- a/src/state/VolumeControls.ts +++ b/src/state/VolumeControls.ts @@ -5,7 +5,16 @@ 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, + distinctUntilChanged, + map, + merge, + of, + skip, + Subject, + switchMap, +} from "rxjs"; import { type Behavior } from "./Behavior"; import { type ObservableScope } from "./ObservableScope"; @@ -25,7 +34,12 @@ export interface VolumeControls { playbackMuted$: Behavior; togglePlaybackMuted: () => void; adjustPlaybackVolume: (value: number) => void; - commitPlaybackVolume: () => 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 { @@ -35,6 +49,10 @@ interface VolumeControlsInputs { * 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; } /** @@ -43,15 +61,22 @@ interface VolumeControlsInputs { */ export function createVolumeControls( scope: ObservableScope, - { pretendToBeDisconnected$, sink$ }: VolumeControlsInputs, + { + pretendToBeDisconnected$, + sink$, + initialVolume = 1, + onCommit, + }: VolumeControlsInputs, ): VolumeControls { const toggleMuted$ = new Subject<"toggle mute">(); const adjustVolume$ = new Subject(); const commitVolume$ = new Subject<"commit">(); - const playbackVolume$ = scope.behavior( + const state$ = scope.behavior( merge(toggleMuted$, adjustVolume$, commitVolume$).pipe( - accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { + accumulate( + { volume: initialVolume, committedVolume: initialVolume }, + (state, event) => { switch (event) { case "toggle mute": return { @@ -71,10 +96,26 @@ export function createVolumeControls( // Volume adjustment return { ...state, volume: event }; } - }), - map(({ volume }) => volume), + }, + ), ), ); + 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([ @@ -96,6 +137,9 @@ export function createVolumeControls( ), togglePlaybackMuted: () => toggleMuted$.next("toggle mute"), adjustPlaybackVolume: (value: number) => adjustVolume$.next(value), - commitPlaybackVolume: () => commitVolume$.next("commit"), + commitPlaybackVolume: (value?: number) => { + if (value !== undefined) adjustVolume$.next(value); + commitVolume$.next("commit"); + }, }; } diff --git a/src/state/media/MediaViewModel.test.ts b/src/state/media/MediaViewModel.test.ts index 9d873ccb..c4ba9840 100644 --- a/src/state/media/MediaViewModel.test.ts +++ b/src/state/media/MediaViewModel.test.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { expect, onTestFinished, test, vi } from "vitest"; +import { afterEach, expect, onTestFinished, test, vi } from "vitest"; import { type LocalTrackPublication, LocalVideoTrack, @@ -44,6 +44,36 @@ vi.mock("../../Platform", () => ({ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA"); +// [lotus #36] Remote volumes are remembered in localStorage; keep tests apart. +afterEach(() => localStorage.clear()); + +test("a remembered volume is restored and a committed slider value is saved", () => { + localStorage.setItem( + "lotus-per-user-volume", + JSON.stringify({ "@alice:example.org": 0.5 }), + ); + const setVolumeSpy = vi.fn(); + const vm = mockRemoteMedia( + rtcMembership, + {}, + mockRemoteParticipant({ setVolume: setVolumeSpy }), + ); + withTestScheduler(({ expectObservable, schedule }) => { + schedule("-a|", { + a() { + // Keyboard order: the slider commits with its final value before that + // value's change event reaches the view model. + vm.commitPlaybackVolume(0.3); + expect(setVolumeSpy).toHaveBeenLastCalledWith(0.3); + expect( + JSON.parse(localStorage.getItem("lotus-per-user-volume") ?? "{}"), + ).toEqual({ "@alice:example.org": 0.3 }); + }, + }); + expectObservable(vm.playbackVolume$).toBe("ab", { a: 0.5, b: 0.3 }); + }); +}); + test("control a participant's volume", () => { const setVolumeSpy = vi.fn(); const vm = mockRemoteMedia( diff --git a/src/state/media/RemoteUserMediaViewModel.ts b/src/state/media/RemoteUserMediaViewModel.ts index 4307dea4..4cff7262 100644 --- a/src/state/media/RemoteUserMediaViewModel.ts +++ b/src/state/media/RemoteUserMediaViewModel.ts @@ -11,6 +11,10 @@ import { combineLatest, map, of, switchMap } from "rxjs"; import { type Behavior } from "../Behavior"; import { createVolumeControls, type VolumeControls } from "../VolumeControls"; +import { + getRememberedVolume, + rememberVolume, +} from "../../lotus/lotusVolumeMemory"; import { type BaseUserMediaInputs, type BaseUserMediaViewModel, @@ -52,6 +56,9 @@ export function createRemoteUserMedia( sink$: scope.behavior( inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))), ), + // [lotus #36] The slider sticks per user across calls and reconnects. + initialVolume: getRememberedVolume(inputs.userId), + onCommit: (volume) => rememberVolume(inputs.userId, volume), }), local: false, speaking$: scope.behavior( diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index abc073fa..1847c88b 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -337,7 +337,10 @@ const ScreenShareVolumeButton: FC = ({ vm }) => { (v: number) => vm.adjustPlaybackVolume(v), [vm], ); - const onVolumeCommit = useCallback(() => vm.commitPlaybackVolume(), [vm]); + const onVolumeCommit = useCallback( + (value: number) => vm.commitPlaybackVolume(value), + [vm], + ); return ( audioEnabled && (