From 7b2fde42809d4aa0ca2e7912bfe5bc000805547c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 1 Dec 2025 16:35:20 +0000 Subject: [PATCH] Retain remoteusermedia modifications across sessions --- src/state/MediaViewModel.ts | 93 ++++++++++++++++++++------------- src/state/RemoteUserSettings.ts | 30 +++++++++++ 2 files changed, 88 insertions(+), 35 deletions(-) create mode 100644 src/state/RemoteUserSettings.ts diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 74e64b932..4e1973e79 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -56,6 +56,7 @@ import { platform } from "../Platform"; import { type MediaDevices } from "./MediaDevices"; import { type Behavior } from "./Behavior"; import { type ObservableScope } from "./ObservableScope"; +import { RemoteUserSetting } from "./RemoteUserSettings"; export function observeTrackReference$( participant: Participant, @@ -398,7 +399,6 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { return this._videoEnabled$; } - private readonly _cropVideo$ = new BehaviorSubject(true); /** * Whether the tile video should be contained inside the tile or be cropped to fit. */ @@ -416,6 +416,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { mxcAvatarUrl$: Behavior, public readonly handRaised$: Behavior, public readonly reaction$: Behavior, + public readonly _cropVideo$ = new BehaviorSubject(true), ) { super( scope, @@ -610,36 +611,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { * The volume to which this participant's audio is set, as a scalar * multiplier. */ - public readonly localVolume$ = this.scope.behavior( - 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), - ), - ); + public readonly localVolume$: Behavior; // This private field is used to override the value from the superclass private __videoEnabled$: Behavior; @@ -650,9 +622,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { /** * Whether this participant's audio is disabled. */ - public readonly locallyMuted$ = this.scope.behavior( - this.localVolume$.pipe(map((volume) => volume === 0)), - ); + public readonly locallyMuted$: Behavior; + + private readonly remoteUserSetting: RemoteUserSetting; public constructor( scope: ObservableScope, @@ -668,6 +640,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { handRaised$: Behavior, reaction$: Behavior, ) { + const remoteUserSetting = new RemoteUserSetting(userId); super( scope, id, @@ -680,6 +653,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { mxcAvatarUrl$, handRaised$, reaction$, + new BehaviorSubject(remoteUserSetting.cropVideo), ); this.__speaking$ = this.scope.behavior( @@ -690,6 +664,47 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { ), ); + this.remoteUserSetting = remoteUserSetting; + const storedVolume = this.remoteUserSetting.getValue().volume; + + this.localVolume$ = this.scope.behavior( + merge( + this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), + this.localVolumeAdjustment$, + this.localVolumeCommit$.pipe(map(() => "commit" as const)), + ).pipe( + accumulate( + { volume: storedVolume, committedVolume: storedVolume }, + (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.locallyMuted$ = this.scope.behavior( + this.localVolume$.pipe(map((volume) => volume === 0)), + ); + this.__videoEnabled$ = this.scope.behavior( pretendToBeDisconnected$.pipe( switchMap((disconnected) => @@ -708,7 +723,10 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { switchMap((disconnected) => (disconnected ? of(0) : this.localVolume$)), this.scope.bind(), ), - ]).subscribe(([p, volume]) => p?.setVolume(volume)); + ]).subscribe(([p, volume]) => { + p?.setVolume(volume); + this.remoteUserSetting.volume = volume; + }); } public toggleLocallyMuted(): void { @@ -723,6 +741,11 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { this.localVolumeCommit$.next(); } + public toggleFitContain(): void { + super.toggleFitContain(); + this.remoteUserSetting.cropVideo = this._cropVideo$.value; + } + public audioStreamStats$ = combineLatest([ this.participant$, showConnectionStats.value$, diff --git a/src/state/RemoteUserSettings.ts b/src/state/RemoteUserSettings.ts new file mode 100644 index 000000000..5c46255c6 --- /dev/null +++ b/src/state/RemoteUserSettings.ts @@ -0,0 +1,30 @@ +import { Setting } from "../settings/settings"; + +export interface RemoteUserSettingData { + volume: number; + cropVideo: boolean; +} + +/** + * A set of local modifications for a remote user's media that should persist + * across calls. + */ +export class RemoteUserSetting extends Setting { + constructor(userId: string) { + super(`remoteusersettings-${userId}`, { volume: 1, cropVideo: true }); + } + + public set volume(volume: number) { + this.setValue({ + ...this.getValue(), + volume, + }); + } + + public set cropVideo(cropVideo: boolean) { + this.setValue({ + ...this.getValue(), + cropVideo, + }); + } +}