Make the volume slider less silly
Previously, dragging it all the way to the left would *not* mute the participant but rather bottom out at 10% volume, and people have found this unintuitive. Let's make it less silly by giving the slider a range of 0% to 100%, and making the mute toggle button have the same effect as dragging the slider to zero. When unmuting, it will reset to the last non-zero "committed" volume, similar to how the volume sliders in desktop environments work.
This commit is contained in:
@@ -16,6 +16,7 @@ interface Props {
|
|||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
onValueChange: (value: number) => void;
|
onValueChange: (value: number) => void;
|
||||||
|
onValueCommit?: (value: number) => void;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
step: number;
|
step: number;
|
||||||
@@ -30,6 +31,7 @@ export const Slider: FC<Props> = ({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onValueChange: onValueChangeProp,
|
onValueChange: onValueChangeProp,
|
||||||
|
onValueCommit: onValueCommitProp,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
@@ -39,12 +41,17 @@ export const Slider: FC<Props> = ({
|
|||||||
([v]: number[]) => onValueChangeProp(v),
|
([v]: number[]) => onValueChangeProp(v),
|
||||||
[onValueChangeProp],
|
[onValueChangeProp],
|
||||||
);
|
);
|
||||||
|
const onValueCommit = useCallback(
|
||||||
|
([v]: number[]) => onValueCommitProp?.(v),
|
||||||
|
[onValueCommitProp],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Root
|
<Root
|
||||||
className={classNames(className, styles.slider)}
|
className={classNames(className, styles.slider)}
|
||||||
value={[value]}
|
value={[value]}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
|
onValueCommit={onValueCommit}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
|
|||||||
@@ -13,43 +13,47 @@ import {
|
|||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
|
|
||||||
test("set a participant's volume", async () => {
|
test("control a participant's volume", async () => {
|
||||||
const setVolumeSpy = vi.fn();
|
const setVolumeSpy = vi.fn();
|
||||||
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
|
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-a|", {
|
schedule("-ab---c---d|", {
|
||||||
a() {
|
|
||||||
vm.setLocalVolume(0.8);
|
|
||||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expectObservable(vm.localVolume).toBe("ab", { a: 1, b: 0.8 });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mute and unmute a participant", async () => {
|
|
||||||
const setVolumeSpy = vi.fn();
|
|
||||||
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
|
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
|
||||||
schedule("-abc|", {
|
|
||||||
a() {
|
a() {
|
||||||
|
// Try muting by toggling
|
||||||
vm.toggleLocallyMuted();
|
vm.toggleLocallyMuted();
|
||||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||||
},
|
},
|
||||||
b() {
|
b() {
|
||||||
|
// Try unmuting by dragging the slider back up
|
||||||
|
vm.setLocalVolume(0.6);
|
||||||
vm.setLocalVolume(0.8);
|
vm.setLocalVolume(0.8);
|
||||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
vm.commitLocalVolume();
|
||||||
|
expect(setVolumeSpy).toHaveBeenCalledWith(0.6);
|
||||||
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||||
},
|
},
|
||||||
c() {
|
c() {
|
||||||
|
// Try muting by dragging the slider back down
|
||||||
|
vm.setLocalVolume(0.2);
|
||||||
|
vm.setLocalVolume(0);
|
||||||
|
vm.commitLocalVolume();
|
||||||
|
expect(setVolumeSpy).toHaveBeenCalledWith(0.2);
|
||||||
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||||
|
},
|
||||||
|
d() {
|
||||||
|
// Try unmuting by toggling
|
||||||
vm.toggleLocallyMuted();
|
vm.toggleLocallyMuted();
|
||||||
|
// The volume should return to the last non-zero committed volume
|
||||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expectObservable(vm.locallyMuted).toBe("ab-c", {
|
expectObservable(vm.localVolume).toBe("ab(cd)(ef)g", {
|
||||||
a: false,
|
a: 1,
|
||||||
b: true,
|
b: 0,
|
||||||
c: false,
|
c: 0.6,
|
||||||
|
d: 0.8,
|
||||||
|
e: 0.2,
|
||||||
|
f: 0,
|
||||||
|
g: 0.8,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
|||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
Observable,
|
Observable,
|
||||||
|
Subject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilKeyChanged,
|
distinctUntilKeyChanged,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
map,
|
map,
|
||||||
|
merge,
|
||||||
of,
|
of,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
@@ -39,6 +41,7 @@ import { useEffect } from "react";
|
|||||||
import { ViewModel } from "./ViewModel";
|
import { ViewModel } from "./ViewModel";
|
||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import { alwaysShowSelf } from "../settings/settings";
|
import { alwaysShowSelf } from "../settings/settings";
|
||||||
|
import { accumulate } from "../utils/observable";
|
||||||
|
|
||||||
// TODO: Move this naming logic into the view model
|
// TODO: Move this naming logic into the view model
|
||||||
export function useDisplayName(vm: MediaViewModel): string {
|
export function useDisplayName(vm: MediaViewModel): string {
|
||||||
@@ -232,18 +235,45 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
* A remote participant's user media.
|
* A remote participant's user media.
|
||||||
*/
|
*/
|
||||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
private readonly _locallyMuted = new BehaviorSubject(false);
|
private readonly locallyMutedToggle = new Subject<void>();
|
||||||
/**
|
private readonly localVolumeAdjustment = new Subject<number>();
|
||||||
* Whether we've disabled this participant's audio.
|
private readonly localVolumeCommit = new Subject<void>();
|
||||||
*/
|
|
||||||
public readonly locallyMuted: Observable<boolean> = this._locallyMuted;
|
|
||||||
|
|
||||||
private readonly _localVolume = new BehaviorSubject(1);
|
|
||||||
/**
|
/**
|
||||||
* The volume to which we've set this participant's audio, as a scalar
|
* The volume to which this participant's audio is set, as a scalar
|
||||||
* multiplier.
|
* multiplier.
|
||||||
*/
|
*/
|
||||||
public readonly localVolume: Observable<number> = this._localVolume;
|
public readonly localVolume: Observable<number> = merge(
|
||||||
|
this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)),
|
||||||
|
this.localVolumeAdjustment,
|
||||||
|
this.localVolumeCommit.pipe(map(() => "commit" as const)),
|
||||||
|
).pipe(
|
||||||
|
accumulate(
|
||||||
|
{ muted: false, volume: 1, committedVolume: 1 },
|
||||||
|
(state, event) =>
|
||||||
|
event === "toggle mute"
|
||||||
|
? { ...state, muted: !state.muted }
|
||||||
|
: event === "commit"
|
||||||
|
? { ...state, committedVolume: state.volume }
|
||||||
|
: // Volume adjustment
|
||||||
|
event === 0
|
||||||
|
? // Dragging the slider to zero should have the same effect as
|
||||||
|
// muting: reset the volume to the committed volume, as if it were
|
||||||
|
// never dragged
|
||||||
|
{ ...state, muted: true, volume: state.committedVolume }
|
||||||
|
: { ...state, muted: false, volume: event },
|
||||||
|
),
|
||||||
|
map(({ muted, volume }) => (muted ? 0 : volume)),
|
||||||
|
this.scope.state(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this participant's audio is disabled.
|
||||||
|
*/
|
||||||
|
public readonly locallyMuted: Observable<boolean> = this.localVolume.pipe(
|
||||||
|
map((volume) => volume === 0),
|
||||||
|
this.scope.state(),
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -253,22 +283,24 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
) {
|
) {
|
||||||
super(id, member, participant, callEncrypted);
|
super(id, member, participant, callEncrypted);
|
||||||
|
|
||||||
// Sync the local mute state and volume with LiveKit
|
// Sync the local volume with LiveKit
|
||||||
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
this.localVolume
|
||||||
muted ? 0 : volume,
|
|
||||||
)
|
|
||||||
.pipe(this.scope.bind())
|
.pipe(this.scope.bind())
|
||||||
.subscribe((volume) => {
|
.subscribe((volume) =>
|
||||||
(this.participant as RemoteParticipant).setVolume(volume);
|
(this.participant as RemoteParticipant).setVolume(volume),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleLocallyMuted(): void {
|
public toggleLocallyMuted(): void {
|
||||||
this._locallyMuted.next(!this._locallyMuted.value);
|
this.locallyMutedToggle.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLocalVolume(value: number): void {
|
public setLocalVolume(value: number): void {
|
||||||
this._localVolume.next(value);
|
this.localVolumeAdjustment.next(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public commitLocalVolume(): void {
|
||||||
|
this.localVolumeCommit.next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ const RemoteUserMediaTile = forwardRef<
|
|||||||
(v: number) => vm.setLocalVolume(v),
|
(v: number) => vm.setLocalVolume(v),
|
||||||
[vm],
|
[vm],
|
||||||
);
|
);
|
||||||
|
const onCommitLocalVolume = useCallback(() => vm.commitLocalVolume(), [vm]);
|
||||||
|
|
||||||
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||||
|
|
||||||
@@ -250,10 +251,10 @@ const RemoteUserMediaTile = forwardRef<
|
|||||||
label={t("video_tile.volume")}
|
label={t("video_tile.volume")}
|
||||||
value={localVolume}
|
value={localVolume}
|
||||||
onValueChange={onChangeLocalVolume}
|
onValueChange={onChangeLocalVolume}
|
||||||
min={0.1}
|
onValueCommit={onCommitLocalVolume}
|
||||||
|
min={0}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
disabled={locallyMuted}
|
|
||||||
/>
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user