Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/settings/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { describe, expect, it, beforeEach } from "vitest";
import {
Setting,
parseResolution,
saveTileVolume,
seedSettingsFromConfig,
tileVolumes,
screenShareCodec,
screenShareResolution,
screenShareFramerate,
Expand Down Expand Up @@ -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,
});
});
});
25 changes: 25 additions & 0 deletions src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, number>>(
"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);
}
85 changes: 85 additions & 0 deletions src/state/VolumeControls.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createVolumeControls>;
sink: ReturnType<typeof vi.fn>;
} {
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);
});
});
85 changes: 62 additions & 23 deletions src/state/VolumeControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -43,39 +63,58 @@ 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<number>();
const commitVolume$ = new Subject<"commit">();

const playbackVolume$ = scope.behavior<number>(
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$,
Expand Down
7 changes: 7 additions & 0 deletions src/state/media/RemoteScreenShareViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -35,12 +36,16 @@ export interface RemoteScreenShareViewModel
export interface RemoteScreenShareInputs extends BaseScreenShareInputs {
participant$: Behavior<RemoteParticipant | null>;
pretendToBeDisconnected$: Behavior<boolean>;
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, {
Expand All @@ -53,6 +58,8 @@ export function createRemoteScreenShare(
),
),
),
initialVolume: tileVolumes.getValue()[savedVolumeKey],
onVolumeCommitted: (volume) => saveTileVolume(savedVolumeKey, volume),
}),
local: false,
videoEnabled$: scope.behavior(
Expand Down
5 changes: 5 additions & 0 deletions src/state/media/RemoteUserMediaViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/utils/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading