Merge pull request #2681 from robintown/volume-slider

Make the volume slider less silly
This commit is contained in:
Robin 2024-10-22 17:25:18 -04:00 committed by GitHub
commit a4faafb3e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 97 additions and 41 deletions

View File

@ -16,6 +16,13 @@ interface Props {
label: string; label: string;
value: number; value: number;
onValueChange: (value: number) => void; onValueChange: (value: number) => void;
/**
* Event handler called when the value changes at the end of an interaction.
* Useful when you only need to capture a final value to update a backend
* service, or when you want to remember the last value that the user
* "committed" to.
*/
onValueCommit?: (value: number) => void;
min: number; min: number;
max: number; max: number;
step: number; step: number;
@ -30,6 +37,7 @@ export const Slider: FC<Props> = ({
label, label,
value, value,
onValueChange: onValueChangeProp, onValueChange: onValueChangeProp,
onValueCommit: onValueCommitProp,
min, min,
max, max,
step, step,
@ -39,12 +47,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}

View File

@ -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,
}); });
}), }),
); );

View File

@ -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,51 @@ 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({ 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 };
}
}),
map(({ volume }) => 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 +289,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();
} }
} }

View File

@ -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>
</> </>