Replace ObservableScope.state with Observable.behavior

This commit is contained in:
Robin
2025-06-18 17:14:21 -04:00
parent 7e81eca068
commit 35ed313577
4 changed files with 670 additions and 657 deletions

View File

@@ -94,6 +94,7 @@ import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array"; import { shallowEquals } from "../utils/array";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type MediaDevices } from "./MediaDevices"; import { type MediaDevices } from "./MediaDevices";
import { type Behavior } from "./Behavior";
// How long we wait after a focus switch before showing the real participant // How long we wait after a focus switch before showing the real participant
// list again // list again
@@ -271,9 +272,9 @@ class UserMedia {
this.participant$.asObservable() as Observable<LocalParticipant>, this.participant$.asObservable() as Observable<LocalParticipant>,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$, displayname$.behavior(this.scope),
handRaised$, handRaised$.behavior(this.scope),
reaction$, reaction$.behavior(this.scope),
); );
} else { } else {
this.vm = new RemoteUserMediaViewModel( this.vm = new RemoteUserMediaViewModel(
@@ -284,15 +285,16 @@ class UserMedia {
>, >,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$, displayname$.behavior(this.scope),
handRaised$, handRaised$.behavior(this.scope),
reaction$, reaction$.behavior(this.scope),
); );
} }
this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state()); this.speaker$ = observeSpeaker$(this.vm.speaking$).behavior(this.scope);
this.presenter$ = this.participant$.pipe( this.presenter$ = this.participant$
.pipe(
switchMap( switchMap(
(p) => (p) =>
(p && (p &&
@@ -305,8 +307,8 @@ class UserMedia {
).pipe(map((p) => p.isScreenShareEnabled))) ?? ).pipe(map((p) => p.isScreenShareEnabled))) ??
of(false), of(false),
), ),
this.scope.state(), )
); .behavior(this.scope);
} }
public updateParticipant( public updateParticipant(
@@ -325,6 +327,7 @@ class UserMedia {
} }
class ScreenShare { class ScreenShare {
private readonly scope = new ObservableScope();
public readonly vm: ScreenShareViewModel; public readonly vm: ScreenShareViewModel;
private readonly participant$: BehaviorSubject< private readonly participant$: BehaviorSubject<
LocalParticipant | RemoteParticipant LocalParticipant | RemoteParticipant
@@ -346,12 +349,13 @@ class ScreenShare {
this.participant$.asObservable(), this.participant$.asObservable(),
encryptionSystem, encryptionSystem,
liveKitRoom, liveKitRoom,
displayname$, displayname$.behavior(this.scope),
participant.isLocal, participant.isLocal,
); );
} }
public destroy(): void { public destroy(): void {
this.scope.end();
this.vm.destroy(); this.vm.destroy();
} }
} }
@@ -397,7 +401,7 @@ export class CallViewModel extends ViewModel {
* The raw list of RemoteParticipants as reported by LiveKit * The raw list of RemoteParticipants as reported by LiveKit
*/ */
private readonly rawRemoteParticipants$: Observable<RemoteParticipant[]> = private readonly rawRemoteParticipants$: Observable<RemoteParticipant[]> =
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state()); connectedParticipantsObserver(this.livekitRoom).behavior(this.scope);
/** /**
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that * Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
@@ -471,7 +475,8 @@ export class CallViewModel extends ViewModel {
fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged), fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged),
// Handle room membership changes (and displayname updates) // Handle room membership changes (and displayname updates)
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members), fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
).pipe( )
.pipe(
startWith(null), startWith(null),
map(() => { map(() => {
const displaynameMap = new Map<string, string>(); const displaynameMap = new Map<string, string>();
@@ -482,7 +487,10 @@ export class CallViewModel extends ViewModel {
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
const { member } = getRoomMemberFromRtcMember(rtcMember, room); const { member } = getRoomMemberFromRtcMember(rtcMember, room);
if (!member) { if (!member) {
logger.error("Could not find member for media id:", matrixIdentifier); logger.error(
"Could not find member for media id:",
matrixIdentifier,
);
continue; continue;
} }
const disambiguate = shouldDisambiguate(member, memberships, room); const disambiguate = shouldDisambiguate(member, memberships, room);
@@ -494,15 +502,15 @@ export class CallViewModel extends ViewModel {
return displaynameMap; return displaynameMap;
}), }),
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower // It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
// than on Chrome/Firefox). This means it is important that we share() the result so that we // than on Chrome/Firefox). This means it is important that we multicast the result so that we
// don't do this work more times than we need to. This is achieve through the state() operator: // don't do this work more times than we need to. This is achieved by converting to a behavior:
this.scope.state(), )
); .behavior(this.scope);
/** /**
* List of MediaItems that we want to display * List of MediaItems that we want to display
*/ */
private readonly mediaItems$: Observable<MediaItem[]> = combineLatest([ private readonly mediaItems$: Behavior<MediaItem[]> = combineLatest([
this.remoteParticipants$, this.remoteParticipants$,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value$, duplicateTiles.value$,
@@ -514,7 +522,8 @@ export class CallViewModel extends ViewModel {
MatrixRTCSessionEvent.MembershipsChanged, MatrixRTCSessionEvent.MembershipsChanged,
).pipe(startWith(null)), ).pipe(startWith(null)),
showNonMemberTiles.value$, showNonMemberTiles.value$,
]).pipe( ])
.pipe(
scan( scan(
( (
prevItems, prevItems,
@@ -644,7 +653,9 @@ export class CallViewModel extends ViewModel {
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom,
this.memberDisplaynames$.pipe( this.memberDisplaynames$.pipe(
map((m) => m.get(participant.identity) ?? "[👻]"), map(
(m) => m.get(participant.identity) ?? "[👻]",
),
), ),
of(null), of(null),
of(null), of(null),
@@ -665,7 +676,8 @@ export class CallViewModel extends ViewModel {
...newItems.entries(), ...newItems.entries(),
]); ]);
for (const [id, t] of prevItems) if (!combinedNew.has(id)) t.destroy(); for (const [id, t] of prevItems)
if (!combinedNew.has(id)) t.destroy();
return combinedNew; return combinedNew;
}, },
new Map<string, MediaItem>(), new Map<string, MediaItem>(),
@@ -674,8 +686,8 @@ export class CallViewModel extends ViewModel {
finalizeValue((ts) => { finalizeValue((ts) => {
for (const t of ts) t.destroy(); for (const t of ts) t.destroy();
}), }),
this.scope.state(), )
); .behavior(this.scope);
/** /**
* List of MediaItems that we want to display, that are of type UserMedia * List of MediaItems that we want to display, that are of type UserMedia
@@ -702,16 +714,17 @@ export class CallViewModel extends ViewModel {
/** /**
* List of MediaItems that we want to display, that are of type ScreenShare * List of MediaItems that we want to display, that are of type ScreenShare
*/ */
private readonly screenShares$: Observable<ScreenShare[]> = private readonly screenShares$: Behavior<ScreenShare[]> = this.mediaItems$
this.mediaItems$.pipe( .pipe(
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
), ),
this.scope.state(), )
); .behavior(this.scope);
private readonly spotlightSpeaker$: Observable<UserMediaViewModel | null> = private readonly spotlightSpeaker$: Behavior<UserMediaViewModel | null> =
this.userMedia$.pipe( this.userMedia$
.pipe(
switchMap((mediaItems) => switchMap((mediaItems) =>
mediaItems.length === 0 mediaItems.length === 0
? of([]) ? of([])
@@ -743,11 +756,11 @@ export class CallViewModel extends ViewModel {
null, null,
), ),
map((speaker) => speaker?.vm ?? null), map((speaker) => speaker?.vm ?? null),
this.scope.state(), )
); .behavior(this.scope);
private readonly grid$: Observable<UserMediaViewModel[]> = private readonly grid$: Behavior<UserMediaViewModel[]> = this.userMedia$
this.userMedia$.pipe( .pipe(
switchMap((mediaItems) => { switchMap((mediaItems) => {
const bins = mediaItems.map((m) => const bins = mediaItems.map((m) =>
combineLatest( combineLatest(
@@ -784,11 +797,11 @@ export class CallViewModel extends ViewModel {
); );
}), }),
distinctUntilChanged(shallowEquals), distinctUntilChanged(shallowEquals),
this.scope.state(), )
); .behavior(this.scope);
private readonly spotlight$: Observable<MediaViewModel[]> = private readonly spotlight$: Behavior<MediaViewModel[]> = this.screenShares$
this.screenShares$.pipe( .pipe(
switchMap((screenShares) => { switchMap((screenShares) => {
if (screenShares.length > 0) { if (screenShares.length > 0) {
return of(screenShares.map((m) => m.vm)); return of(screenShares.map((m) => m.vm));
@@ -799,14 +812,15 @@ export class CallViewModel extends ViewModel {
); );
}), }),
distinctUntilChanged(shallowEquals), distinctUntilChanged(shallowEquals),
this.scope.state(), )
); .behavior(this.scope);
private readonly pip$: Observable<UserMediaViewModel | null> = combineLatest([ private readonly pip$: Behavior<UserMediaViewModel | null> = combineLatest([
this.screenShares$, this.screenShares$,
this.spotlightSpeaker$, this.spotlightSpeaker$,
this.mediaItems$, this.mediaItems$,
]).pipe( ])
.pipe(
switchMap(([screenShares, spotlight, mediaItems]) => { switchMap(([screenShares, spotlight, mediaItems]) => {
if (screenShares.length > 0) { if (screenShares.length > 0) {
return this.spotlightSpeaker$; return this.spotlightSpeaker$;
@@ -836,8 +850,8 @@ export class CallViewModel extends ViewModel {
}), }),
); );
}), }),
this.scope.state(), )
); .behavior(this.scope);
private readonly hasRemoteScreenShares$: Observable<boolean> = private readonly hasRemoteScreenShares$: Observable<boolean> =
this.spotlight$.pipe( this.spotlight$.pipe(
@@ -851,10 +865,11 @@ export class CallViewModel extends ViewModel {
startWith(false), startWith(false),
); );
private readonly naturalWindowMode$: Observable<WindowMode> = fromEvent( private readonly naturalWindowMode$: Behavior<WindowMode> = fromEvent(
window, window,
"resize", "resize",
).pipe( )
.pipe(
startWith(null), startWith(null),
map(() => { map(() => {
const height = window.innerHeight; const height = window.innerHeight;
@@ -867,36 +882,43 @@ export class CallViewModel extends ViewModel {
if (width <= 600) return "narrow"; if (width <= 600) return "narrow";
return "normal"; return "normal";
}), }),
this.scope.state(), )
); .behavior(this.scope);
/** /**
* The general shape of the window. * The general shape of the window.
*/ */
public readonly windowMode$: Observable<WindowMode> = this.pipEnabled$.pipe( public readonly windowMode$: Behavior<WindowMode> = this.pipEnabled$
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode$)), .pipe(
); switchMap((pip) =>
pip ? of<WindowMode>("pip") : this.naturalWindowMode$,
),
)
.behavior(this.scope);
private readonly spotlightExpandedToggle$ = new Subject<void>(); private readonly spotlightExpandedToggle$ = new Subject<void>();
public readonly spotlightExpanded$: Observable<boolean> = public readonly spotlightExpanded$: Behavior<boolean> =
this.spotlightExpandedToggle$.pipe( this.spotlightExpandedToggle$
accumulate(false, (expanded) => !expanded), .pipe(accumulate(false, (expanded) => !expanded))
this.scope.state(), .behavior(this.scope);
);
private readonly gridModeUserSelection$ = new Subject<GridMode>(); private readonly gridModeUserSelection$ = new Subject<GridMode>();
/** /**
* The layout mode of the media tile grid. * The layout mode of the media tile grid.
*/ */
public readonly gridMode$: Observable<GridMode> = public readonly gridMode$: Behavior<GridMode> =
// If the user hasn't selected spotlight and somebody starts screen sharing, // If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends // automatically switch to spotlight mode and reset when screen sharing ends
this.gridModeUserSelection$.pipe( this.gridModeUserSelection$
.pipe(
startWith(null), startWith(null),
switchMap((userSelection) => switchMap((userSelection) =>
(userSelection === "spotlight" (userSelection === "spotlight"
? EMPTY ? EMPTY
: combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe( : combineLatest([
this.hasRemoteScreenShares$,
this.windowMode$,
]).pipe(
skip(userSelection === null ? 0 : 1), skip(userSelection === null ? 0 : 1),
map( map(
([hasScreenShares, windowMode]): GridMode => ([hasScreenShares, windowMode]): GridMode =>
@@ -907,8 +929,8 @@ export class CallViewModel extends ViewModel {
) )
).pipe(startWith(userSelection ?? "grid")), ).pipe(startWith(userSelection ?? "grid")),
), ),
this.scope.state(), )
); .behavior(this.scope);
public setGridMode(value: GridMode): void { public setGridMode(value: GridMode): void {
this.gridModeUserSelection$.next(value); this.gridModeUserSelection$.next(value);
@@ -969,8 +991,8 @@ export class CallViewModel extends ViewModel {
/** /**
* The media to be used to produce a layout. * The media to be used to produce a layout.
*/ */
private readonly layoutMedia$: Observable<LayoutMedia> = private readonly layoutMedia$: Behavior<LayoutMedia> = this.windowMode$
this.windowMode$.pipe( .pipe(
switchMap((windowMode) => { switchMap((windowMode) => {
switch (windowMode) { switch (windowMode) {
case "normal": case "normal":
@@ -1032,8 +1054,8 @@ export class CallViewModel extends ViewModel {
return this.pipLayoutMedia$; return this.pipLayoutMedia$;
} }
}), }),
this.scope.state(), )
); .behavior(this.scope);
// There is a cyclical dependency here: the layout algorithms want to know // There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to // which tiles are on screen, but to know which tiles are on screen we have to
@@ -1043,12 +1065,13 @@ export class CallViewModel extends ViewModel {
private readonly setVisibleTiles = (value: number): void => private readonly setVisibleTiles = (value: number): void =>
this.visibleTiles$.next(value); this.visibleTiles$.next(value);
public readonly layoutInternals$: Observable< private readonly layoutInternals$: Behavior<
LayoutScanState & { layout: Layout } LayoutScanState & { layout: Layout }
> = combineLatest([ > = combineLatest([
this.layoutMedia$, this.layoutMedia$,
this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()), this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
]).pipe( ])
.pipe(
scan< scan<
[LayoutMedia, number], [LayoutMedia, number],
LayoutScanState & { layout: Layout }, LayoutScanState & { layout: Layout },
@@ -1083,32 +1106,29 @@ export class CallViewModel extends ViewModel {
}, },
{ layout: null, tiles: TileStore.empty() }, { layout: null, tiles: TileStore.empty() },
), ),
this.scope.state(), )
); .behavior(this.scope);
/** /**
* The layout of tiles in the call interface. * The layout of tiles in the call interface.
*/ */
public readonly layout$: Observable<Layout> = this.layoutInternals$.pipe( public readonly layout$: Behavior<Layout> = this.layoutInternals$
map(({ layout }) => layout), .pipe(map(({ layout }) => layout))
this.scope.state(), .behavior(this.scope);
);
/** /**
* The current generation of the tile store, exposed for debugging purposes. * The current generation of the tile store, exposed for debugging purposes.
*/ */
public readonly tileStoreGeneration$: Observable<number> = public readonly tileStoreGeneration$: Behavior<number> = this.layoutInternals$
this.layoutInternals$.pipe( .pipe(map(({ tiles }) => tiles.generation))
map(({ tiles }) => tiles.generation), .behavior(this.scope);
this.scope.state(),
);
public showSpotlightIndicators$: Observable<boolean> = this.layout$.pipe( public showSpotlightIndicators$: Behavior<boolean> = this.layout$
map((l) => l.type !== "grid"), .pipe(map((l) => l.type !== "grid"))
this.scope.state(), .behavior(this.scope);
);
public showSpeakingIndicators$: Observable<boolean> = this.layout$.pipe( public showSpeakingIndicators$: Behavior<boolean> = this.layout$
.pipe(
switchMap((l) => { switchMap((l) => {
switch (l.type) { switch (l.type) {
case "spotlight-landscape": case "spotlight-landscape":
@@ -1132,11 +1152,12 @@ export class CallViewModel extends ViewModel {
return of(true); return of(true);
} }
}), }),
this.scope.state(), )
); .behavior(this.scope);
public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> = public readonly toggleSpotlightExpanded$: Behavior<(() => void) | null> =
this.windowMode$.pipe( this.windowMode$
.pipe(
switchMap((mode) => switchMap((mode) =>
mode === "normal" mode === "normal"
? this.layout$.pipe( ? this.layout$.pipe(
@@ -1152,8 +1173,8 @@ export class CallViewModel extends ViewModel {
map((enabled) => map((enabled) =>
enabled ? (): void => this.spotlightExpandedToggle$.next() : null, enabled ? (): void => this.spotlightExpandedToggle$.next() : null,
), ),
this.scope.state(), )
); .behavior(this.scope);
private readonly screenTap$ = new Subject<void>(); private readonly screenTap$ = new Subject<void>();
private readonly controlsTap$ = new Subject<void>(); private readonly controlsTap$ = new Subject<void>();
@@ -1188,12 +1209,12 @@ export class CallViewModel extends ViewModel {
this.screenUnhover$.next(); this.screenUnhover$.next();
} }
public readonly showHeader$: Observable<boolean> = this.windowMode$.pipe( public readonly showHeader$: Behavior<boolean> = this.windowMode$
map((mode) => mode !== "pip" && mode !== "flat"), .pipe(map((mode) => mode !== "pip" && mode !== "flat"))
this.scope.state(), .behavior(this.scope);
);
public readonly showFooter$: Observable<boolean> = this.windowMode$.pipe( public readonly showFooter$: Behavior<boolean> = this.windowMode$
.pipe(
switchMap((mode) => { switchMap((mode) => {
switch (mode) { switch (mode) {
case "pip": case "pip":
@@ -1244,8 +1265,8 @@ export class CallViewModel extends ViewModel {
); );
} }
}), }),
this.scope.state(), )
); .behavior(this.scope);
/** /**
* Whether audio is currently being output through the earpiece. * Whether audio is currently being output through the earpiece.
@@ -1292,20 +1313,26 @@ export class CallViewModel extends ViewModel {
}, },
); );
public readonly reactions$ = this.reactionsSubject$.pipe( public readonly reactions$ = this.reactionsSubject$
.pipe(
map((v) => map((v) =>
Object.fromEntries( Object.fromEntries(
Object.entries(v).map(([a, { reactionOption }]) => [a, reactionOption]), Object.entries(v).map(([a, { reactionOption }]) => [
a,
reactionOption,
]),
), ),
), ),
); )
.behavior(this.scope);
public readonly handsRaised$ = this.handsRaisedSubject$.pipe(); public readonly handsRaised$ = this.handsRaisedSubject$.behavior(this.scope);
/** /**
* Emits an array of reactions that should be visible on the screen. * Emits an array of reactions that should be visible on the screen.
*/ */
public readonly visibleReactions$ = showReactions.value$.pipe( public readonly visibleReactions$ = showReactions.value$
.pipe(
switchMap((show) => (show ? this.reactions$ : of({}))), switchMap((show) => (show ? this.reactions$ : of({}))),
scan< scan<
Record<string, ReactionOption>, Record<string, ReactionOption>,
@@ -1320,7 +1347,8 @@ export class CallViewModel extends ViewModel {
} }
return newSet; return newSet;
}, []), }, []),
); )
.behavior(this.scope);
/** /**
* Emits an array of reactions that should be played. * Emits an array of reactions that should be played.

View File

@@ -51,6 +51,7 @@ import { accumulate } from "../utils/observable";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
import { type Behavior } from "./Behavior";
export function observeTrackReference$( export function observeTrackReference$(
participant$: Observable<Participant | undefined>, participant$: Observable<Participant | undefined>,
@@ -223,13 +224,13 @@ abstract class BaseMediaViewModel extends ViewModel {
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>; public readonly video$: Behavior<TrackReferenceOrPlaceholder | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
public readonly unencryptedWarning$: Observable<boolean>; public readonly unencryptedWarning$: Behavior<boolean>;
public readonly encryptionStatus$: Observable<EncryptionStatus>; public readonly encryptionStatus$: Behavior<EncryptionStatus>;
/** /**
* Whether this media corresponds to the local participant. * Whether this media corresponds to the local participant.
@@ -260,11 +261,11 @@ abstract class BaseMediaViewModel extends ViewModel {
public readonly displayname$: Observable<string>, public readonly displayname$: Observable<string>,
) { ) {
super(); super();
const audio$ = observeTrackReference$(participant$, audioSource).pipe( const audio$ = observeTrackReference$(participant$, audioSource).behavior(
this.scope.state(), this.scope,
); );
this.video$ = observeTrackReference$(participant$, videoSource).pipe( this.video$ = observeTrackReference$(participant$, videoSource).behavior(
this.scope.state(), this.scope,
); );
this.unencryptedWarning$ = combineLatest( this.unencryptedWarning$ = combineLatest(
[audio$, this.video$], [audio$, this.video$],
@@ -272,9 +273,10 @@ abstract class BaseMediaViewModel extends ViewModel {
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false || (a?.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false), v?.publication?.isEncrypted === false),
).pipe(this.scope.state()); ).behavior(this.scope);
this.encryptionStatus$ = this.participant$.pipe( this.encryptionStatus$ = this.participant$
.pipe(
switchMap((participant): Observable<EncryptionStatus> => { switchMap((participant): Observable<EncryptionStatus> => {
if (!participant) { if (!participant) {
return of(EncryptionStatus.Connecting); return of(EncryptionStatus.Connecting);
@@ -334,8 +336,8 @@ abstract class BaseMediaViewModel extends ViewModel {
); );
} }
}), }),
this.scope.state(), )
); .behavior(this.scope);
} }
} }
@@ -354,31 +356,33 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking$ = this.participant$.pipe( public readonly speaking$ = this.participant$
.pipe(
switchMap((p) => switchMap((p) =>
p p
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe( ? observeParticipantEvents(
map((p) => p.isSpeaking), p,
) ParticipantEvent.IsSpeakingChanged,
).pipe(map((p) => p.isSpeaking))
: of(false), : of(false),
), ),
this.scope.state(), )
); .behavior(this.scope);
/** /**
* Whether this participant is sending audio (i.e. is unmuted on their side). * Whether this participant is sending audio (i.e. is unmuted on their side).
*/ */
public readonly audioEnabled$: Observable<boolean>; public readonly audioEnabled$: Behavior<boolean>;
/** /**
* Whether this participant is sending video. * Whether this participant is sending video.
*/ */
public readonly videoEnabled$: Observable<boolean>; public readonly videoEnabled$: Behavior<boolean>;
private readonly _cropVideo$ = new BehaviorSubject(true); private readonly _cropVideo$ = new BehaviorSubject(true);
/** /**
* Whether the tile video should be contained inside the tile or be cropped to fit. * Whether the tile video should be contained inside the tile or be cropped to fit.
*/ */
public readonly cropVideo$: Observable<boolean> = this._cropVideo$; public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
public constructor( public constructor(
id: string, id: string,
@@ -387,8 +391,8 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayname$: Observable<string>,
public readonly handRaised$: Observable<Date | null>, public readonly handRaised$: Behavior<Date | null>,
public readonly reaction$: Observable<ReactionOption | null>, public readonly reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,
@@ -401,16 +405,17 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
displayname$, displayname$,
); );
const media$ = participant$.pipe( const media$ = participant$
.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
this.scope.state(), )
); .behavior(this.scope);
this.audioEnabled$ = media$.pipe( this.audioEnabled$ = media$
map((m) => m?.microphoneTrack?.isMuted === false), .pipe(map((m) => m?.microphoneTrack?.isMuted === false))
); .behavior(this.scope);
this.videoEnabled$ = media$.pipe( this.videoEnabled$ = media$
map((m) => m?.cameraTrack?.isMuted === false), .pipe(map((m) => m?.cameraTrack?.isMuted === false))
); .behavior(this.scope);
} }
public toggleFitContain(): void { public toggleFitContain(): void {
@@ -436,7 +441,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether the video should be mirrored. * Whether the video should be mirrored.
*/ */
public readonly mirror$ = this.video$.pipe( public readonly mirror$ = this.video$
.pipe(
switchMap((v) => { switchMap((v) => {
const track = v?.publication?.track; const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false); if (!(track instanceof LocalTrack)) return of(false);
@@ -447,8 +453,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
map(() => facingModeFromLocalTrack(track).facingMode === "user"), map(() => facingModeFromLocalTrack(track).facingMode === "user"),
); );
}), }),
this.scope.state(), )
); .behavior(this.scope);
/** /**
* Whether to show this tile in a highly visible location near the start of * Whether to show this tile in a highly visible location near the start of
@@ -464,8 +470,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayname$: Observable<string>,
handRaised$: Observable<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Observable<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,
@@ -512,11 +518,12 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
* The volume to which this participant's audio is set, as a scalar * The volume to which this participant's audio is set, as a scalar
* multiplier. * multiplier.
*/ */
public readonly localVolume$: Observable<number> = merge( public readonly localVolume$: Behavior<number> = merge(
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment$, this.localVolumeAdjustment$,
this.localVolumeCommit$.pipe(map(() => "commit" as const)), this.localVolumeCommit$.pipe(map(() => "commit" as const)),
).pipe( )
.pipe(
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
switch (event) { switch (event) {
case "toggle mute": case "toggle mute":
@@ -539,16 +546,15 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
} }
}), }),
map(({ volume }) => volume), map(({ volume }) => volume),
this.scope.state(), )
); .behavior(this.scope);
/** /**
* Whether this participant's audio is disabled. * Whether this participant's audio is disabled.
*/ */
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe( public readonly locallyMuted$: Behavior<boolean> = this.localVolume$
map((volume) => volume === 0), .pipe(map((volume) => volume === 0))
this.scope.state(), .behavior(this.scope);
);
public constructor( public constructor(
id: string, id: string,
@@ -557,8 +563,8 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayname$: Observable<string>,
handRaised$: Observable<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Observable<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,

View File

@@ -5,13 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { import { type Observable, Subject, takeUntil } from "rxjs";
distinctUntilChanged,
type Observable,
shareReplay,
Subject,
takeUntil,
} from "rxjs";
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>; type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
@@ -31,22 +25,6 @@ export class ObservableScope {
return this.bindImpl; return this.bindImpl;
} }
private readonly stateImpl: MonoTypeOperator = (o$) =>
o$.pipe(
this.bind(),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }),
);
/**
* Transforms an Observable into a hot state Observable which replays its
* latest value upon subscription, skips updates with identical values, and
* is bound to this scope.
*/
public state(): MonoTypeOperator {
return this.stateImpl;
}
/** /**
* Ends the scope, causing any bound Observables to complete. * Ends the scope, causing any bound Observables to complete.
*/ */

View File

@@ -47,6 +47,7 @@ import {
} from "../config/ConfigOptions"; } from "../config/ConfigOptions";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior";
export function withFakeTimers(continuation: () => void): void { export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -217,8 +218,8 @@ export async function withLocalMedia(
}, },
mockLivekitRoom({ localParticipant }), mockLivekitRoom({ localParticipant }),
of(roomMember.rawDisplayName ?? "nodisplayname"), of(roomMember.rawDisplayName ?? "nodisplayname"),
of(null), constant(null),
of(null), constant(null),
); );
try { try {
await continuation(vm); await continuation(vm);
@@ -256,8 +257,8 @@ export async function withRemoteMedia(
}, },
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }), mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
of(roomMember.rawDisplayName ?? "nodisplayname"), of(roomMember.rawDisplayName ?? "nodisplayname"),
of(null), constant(null),
of(null), constant(null),
); );
try { try {
await continuation(vm); await continuation(vm);