Move 'behavior' to be a method on ObservableScope

This commit is contained in:
Robin
2025-07-12 00:20:44 -04:00
parent 32bf1c30d2
commit 2b76d3dd70
7 changed files with 406 additions and 402 deletions

View File

@@ -5,9 +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 { BehaviorSubject, distinctUntilChanged, Observable } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type ObservableScope } from "./ObservableScope";
/** /**
* A stateful, read-only reactive value. As an Observable, it is "hot" and * A stateful, read-only reactive value. As an Observable, it is "hot" and
@@ -26,38 +24,3 @@ export type Behavior<T> = Omit<BehaviorSubject<T>, "next" | "observers">;
export function constant<T>(value: T): Behavior<T> { export function constant<T>(value: T): Behavior<T> {
return new BehaviorSubject(value); return new BehaviorSubject(value);
} }
declare module "rxjs" {
interface Observable<T> {
/**
* Converts this Observable into a Behavior. This requires the Observable to
* synchronously emit an initial value.
*/
behavior(scope: ObservableScope): Behavior<T>;
}
}
const nothing = Symbol("nothing");
Observable.prototype.behavior = function <T>(
this: Observable<T>,
scope: ObservableScope,
): Behavior<T> {
const subject$ = new BehaviorSubject<T | typeof nothing>(nothing);
// Push values from the Observable into the BehaviorSubject.
// BehaviorSubjects have an undesirable feature where if you call 'complete',
// they will no longer re-emit their current value upon subscription. We want
// to support Observables that complete (for example `of({})`), so we have to
// take care to not propagate the completion event.
this.pipe(scope.bind(), distinctUntilChanged()).subscribe({
next(value) {
subject$.next(value);
},
error(err) {
subject$.error(err);
},
});
if (subject$.value === nothing)
throw new Error("Behavior failed to synchronously emit an initial value");
return subject$ as Behavior<T>;
};

View File

@@ -272,9 +272,9 @@ class UserMedia {
this.participant$ as Behavior<LocalParticipant>, this.participant$ as Behavior<LocalParticipant>,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$.behavior(this.scope), this.scope.behavior(displayname$),
handRaised$.behavior(this.scope), this.scope.behavior(handRaised$),
reaction$.behavior(this.scope), this.scope.behavior(reaction$),
); );
} else { } else {
this.vm = new RemoteUserMediaViewModel( this.vm = new RemoteUserMediaViewModel(
@@ -285,16 +285,16 @@ class UserMedia {
>, >,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$.behavior(this.scope), this.scope.behavior(displayname$),
handRaised$.behavior(this.scope), this.scope.behavior(handRaised$),
reaction$.behavior(this.scope), this.scope.behavior(reaction$),
); );
} }
this.speaker$ = observeSpeaker$(this.vm.speaking$).behavior(this.scope); this.speaker$ = this.scope.behavior(observeSpeaker$(this.vm.speaking$));
this.presenter$ = this.participant$ this.presenter$ = this.scope.behavior(
.pipe( this.participant$.pipe(
switchMap( switchMap(
(p) => (p) =>
(p && (p &&
@@ -307,8 +307,8 @@ class UserMedia {
).pipe(map((p) => p.isScreenShareEnabled))) ?? ).pipe(map((p) => p.isScreenShareEnabled))) ??
of(false), of(false),
), ),
) ),
.behavior(this.scope); );
} }
public updateParticipant( public updateParticipant(
@@ -349,7 +349,7 @@ class ScreenShare {
this.participant$.asObservable(), this.participant$.asObservable(),
encryptionSystem, encryptionSystem,
liveKitRoom, liveKitRoom,
displayName$.behavior(this.scope), this.scope.behavior(displayName$),
participant.isLocal, participant.isLocal,
); );
} }
@@ -386,71 +386,72 @@ function getRoomMemberFromRtcMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here // TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel { export class CallViewModel extends ViewModel {
public readonly localVideo$: Behavior<LocalVideoTrack | null> = public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
observeTrackReference$( observeTrackReference$(
this.livekitRoom.localParticipant, this.livekitRoom.localParticipant,
Track.Source.Camera, Track.Source.Camera,
) ).pipe(
.pipe( map((trackRef) => {
map((trackRef) => { const track = trackRef?.publication?.track;
const track = trackRef?.publication?.track; return track instanceof LocalVideoTrack ? track : null;
return track instanceof LocalVideoTrack ? track : null; }),
}), ),
) );
.behavior(this.scope);
/** /**
* The raw list of RemoteParticipants as reported by LiveKit * The raw list of RemoteParticipants as reported by LiveKit
*/ */
private readonly rawRemoteParticipants$: Behavior<RemoteParticipant[]> = private readonly rawRemoteParticipants$ = this.scope.behavior<
connectedParticipantsObserver(this.livekitRoom) RemoteParticipant[]
.pipe(startWith([])) >(connectedParticipantsObserver(this.livekitRoom).pipe(startWith([])));
.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
* they've left * they've left
*/ */
private readonly remoteParticipantHolds$: Behavior<RemoteParticipant[][]> = private readonly remoteParticipantHolds$ = this.scope.behavior<
this.connectionState$ RemoteParticipant[][]
.pipe( >(
withLatestFrom(this.rawRemoteParticipants$), this.connectionState$.pipe(
mergeMap(([s, ps]) => { withLatestFrom(this.rawRemoteParticipants$),
// Whenever we switch focuses, we should retain all the previous mergeMap(([s, ps]) => {
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to // Whenever we switch focuses, we should retain all the previous
// give their clients time to switch over and avoid jarring layout shifts // participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
if (s === ECAddonConnectionState.ECSwitchingFocus) { // give their clients time to switch over and avoid jarring layout shifts
return concat( if (s === ECAddonConnectionState.ECSwitchingFocus) {
// Hold these participants return concat(
of({ hold: ps }), // Hold these participants
// Wait for time to pass and the connection state to have changed of({ hold: ps }),
forkJoin([ // Wait for time to pass and the connection state to have changed
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS), forkJoin([
this.connectionState$.pipe( timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus), this.connectionState$.pipe(
take(1), filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
), take(1),
// Then unhold them ),
]).pipe(map(() => ({ unhold: ps }))), // Then unhold them
); ]).pipe(map(() => ({ unhold: ps }))),
} else { );
return EMPTY; } else {
} return EMPTY;
}), }
// Accumulate the hold instructions into a single list showing which }),
// participants are being held // Accumulate the hold instructions into a single list showing which
accumulate([] as RemoteParticipant[][], (holds, instruction) => // participants are being held
"hold" in instruction accumulate([] as RemoteParticipant[][], (holds, instruction) =>
? [instruction.hold, ...holds] "hold" in instruction
: holds.filter((h) => h !== instruction.unhold), ? [instruction.hold, ...holds]
), : holds.filter((h) => h !== instruction.unhold),
) ),
.behavior(this.scope); ),
);
/** /**
* The RemoteParticipants including those that are being "held" on the screen * The RemoteParticipants including those that are being "held" on the screen
*/ */
private readonly remoteParticipants$: Behavior<RemoteParticipant[]> = private readonly remoteParticipants$ = this.scope.behavior<
RemoteParticipant[]
>(
combineLatest( combineLatest(
[this.rawRemoteParticipants$, this.remoteParticipantHolds$], [this.rawRemoteParticipants$, this.remoteParticipantHolds$],
(raw, holds) => { (raw, holds) => {
@@ -469,20 +470,24 @@ export class CallViewModel extends ViewModel {
return result; return result;
}, },
).behavior(this.scope); ),
);
/** /**
* Displaynames for each member of the call. This will disambiguate * Displaynames for each member of the call. This will disambiguate
* any displaynames that clashes with another member. Only members * any displaynames that clashes with another member. Only members
* joined to the call are considered here. * joined to the call are considered here.
*/ */
public readonly memberDisplaynames$ = merge( public readonly memberDisplaynames$ = this.scope.behavior(
// Handle call membership changes. merge(
fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged), // Handle call membership changes.
// Handle room membership changes (and displayname updates) fromEvent(
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members), this.matrixRTCSession,
) MatrixRTCSessionEvent.MembershipsChanged,
.pipe( ),
// Handle room membership changes (and displayname updates)
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
).pipe(
startWith(null), startWith(null),
map(() => { map(() => {
const displaynameMap = new Map<string, string>(); const displaynameMap = new Map<string, string>();
@@ -510,13 +515,13 @@ export class CallViewModel extends ViewModel {
// 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 multicast 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 achieved by converting to a behavior: // don't do this work more times than we need to. This is achieved by converting to a behavior:
) ),
.behavior(this.scope); );
public readonly handsRaised$ = this.handsRaisedSubject$.behavior(this.scope); public readonly handsRaised$ = this.scope.behavior(this.handsRaisedSubject$);
public readonly reactions$ = this.reactionsSubject$ public readonly reactions$ = this.scope.behavior(
.pipe( this.reactionsSubject$.pipe(
map((v) => map((v) =>
Object.fromEntries( Object.fromEntries(
Object.entries(v).map(([a, { reactionOption }]) => [ Object.entries(v).map(([a, { reactionOption }]) => [
@@ -525,26 +530,26 @@ export class CallViewModel extends ViewModel {
]), ]),
), ),
), ),
) ),
.behavior(this.scope); );
/** /**
* List of MediaItems that we want to display * List of MediaItems that we want to display
*/ */
private readonly mediaItems$: Behavior<MediaItem[]> = combineLatest([ private readonly mediaItems$ = this.scope.behavior<MediaItem[]>(
this.remoteParticipants$, combineLatest([
observeParticipantMedia(this.livekitRoom.localParticipant), this.remoteParticipants$,
duplicateTiles.value$, observeParticipantMedia(this.livekitRoom.localParticipant),
// Also react to changes in the MatrixRTC session list. duplicateTiles.value$,
// The session list will also be update if a room membership changes. // Also react to changes in the MatrixRTC session list.
// No additional RoomState event listener needs to be set up. // The session list will also be update if a room membership changes.
fromEvent( // No additional RoomState event listener needs to be set up.
this.matrixRTCSession, fromEvent(
MatrixRTCSessionEvent.MembershipsChanged, this.matrixRTCSession,
).pipe(startWith(null)), MatrixRTCSessionEvent.MembershipsChanged,
showNonMemberTiles.value$, ).pipe(startWith(null)),
]) showNonMemberTiles.value$,
.pipe( ]).pipe(
scan( scan(
( (
prevItems, prevItems,
@@ -707,19 +712,19 @@ export class CallViewModel extends ViewModel {
finalizeValue((ts) => { finalizeValue((ts) => {
for (const t of ts) t.destroy(); for (const t of ts) t.destroy();
}), }),
) ),
.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
*/ */
private readonly userMedia$: Behavior<UserMedia[]> = this.mediaItems$ private readonly userMedia$ = this.scope.behavior<UserMedia[]>(
.pipe( this.mediaItems$.pipe(
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia), mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
), ),
) ),
.behavior(this.scope); );
public readonly memberChanges$ = this.userMedia$ public readonly memberChanges$ = this.userMedia$
.pipe(map((mediaItems) => mediaItems.map((m) => m.id))) .pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
@@ -737,17 +742,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$: Behavior<ScreenShare[]> = this.mediaItems$ private readonly screenShares$ = this.scope.behavior<ScreenShare[]>(
.pipe( this.mediaItems$.pipe(
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
), ),
) ),
.behavior(this.scope); );
private readonly spotlightSpeaker$: Behavior<UserMediaViewModel | null> = private readonly spotlightSpeaker$ =
this.userMedia$ this.scope.behavior<UserMediaViewModel | null>(
.pipe( this.userMedia$.pipe(
switchMap((mediaItems) => switchMap((mediaItems) =>
mediaItems.length === 0 mediaItems.length === 0
? of([]) ? of([])
@@ -779,11 +784,11 @@ export class CallViewModel extends ViewModel {
null, null,
), ),
map((speaker) => speaker?.vm ?? null), map((speaker) => speaker?.vm ?? null),
) ),
.behavior(this.scope); );
private readonly grid$: Behavior<UserMediaViewModel[]> = this.userMedia$ private readonly grid$ = this.scope.behavior<UserMediaViewModel[]>(
.pipe( this.userMedia$.pipe(
switchMap((mediaItems) => { switchMap((mediaItems) => {
const bins = mediaItems.map((m) => const bins = mediaItems.map((m) =>
combineLatest( combineLatest(
@@ -820,11 +825,11 @@ export class CallViewModel extends ViewModel {
); );
}), }),
distinctUntilChanged(shallowEquals), distinctUntilChanged(shallowEquals),
) ),
.behavior(this.scope); );
private readonly spotlight$: Behavior<MediaViewModel[]> = this.screenShares$ private readonly spotlight$ = this.scope.behavior<MediaViewModel[]>(
.pipe( this.screenShares$.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));
@@ -835,15 +840,15 @@ export class CallViewModel extends ViewModel {
); );
}), }),
distinctUntilChanged(shallowEquals), distinctUntilChanged(shallowEquals),
) ),
.behavior(this.scope); );
private readonly pip$: Behavior<UserMediaViewModel | null> = combineLatest([ private readonly pip$ = this.scope.behavior<UserMediaViewModel | null>(
this.screenShares$, combineLatest([
this.spotlightSpeaker$, this.screenShares$,
this.mediaItems$, this.spotlightSpeaker$,
]) 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$;
@@ -873,8 +878,8 @@ export class CallViewModel extends ViewModel {
}), }),
); );
}), }),
) ),
.behavior(this.scope); );
private readonly hasRemoteScreenShares$: Observable<boolean> = private readonly hasRemoteScreenShares$: Observable<boolean> =
this.spotlight$.pipe( this.spotlight$.pipe(
@@ -888,11 +893,8 @@ export class CallViewModel extends ViewModel {
startWith(false), startWith(false),
); );
private readonly naturalWindowMode$: Behavior<WindowMode> = fromEvent( private readonly naturalWindowMode$ = this.scope.behavior<WindowMode>(
window, fromEvent(window, "resize").pipe(
"resize",
)
.pipe(
startWith(null), startWith(null),
map(() => { map(() => {
const height = window.innerHeight; const height = window.innerHeight;
@@ -905,35 +907,36 @@ export class CallViewModel extends ViewModel {
if (width <= 600) return "narrow"; if (width <= 600) return "narrow";
return "normal"; return "normal";
}), }),
) ),
.behavior(this.scope); );
/** /**
* The general shape of the window. * The general shape of the window.
*/ */
public readonly windowMode$: Behavior<WindowMode> = this.pipEnabled$ public readonly windowMode$ = this.scope.behavior<WindowMode>(
.pipe( this.pipEnabled$.pipe(
switchMap((pip) => switchMap((pip) =>
pip ? of<WindowMode>("pip") : this.naturalWindowMode$, pip ? of<WindowMode>("pip") : this.naturalWindowMode$,
), ),
) ),
.behavior(this.scope); );
private readonly spotlightExpandedToggle$ = new Subject<void>(); private readonly spotlightExpandedToggle$ = new Subject<void>();
public readonly spotlightExpanded$: Behavior<boolean> = public readonly spotlightExpanded$ = this.scope.behavior<boolean>(
this.spotlightExpandedToggle$ this.spotlightExpandedToggle$.pipe(
.pipe(accumulate(false, (expanded) => !expanded)) accumulate(false, (expanded) => !expanded),
.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$: Behavior<GridMode> = public readonly 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$ this.scope.behavior<GridMode>(
.pipe( this.gridModeUserSelection$.pipe(
startWith(null), startWith(null),
switchMap((userSelection) => switchMap((userSelection) =>
(userSelection === "spotlight" (userSelection === "spotlight"
@@ -952,8 +955,8 @@ export class CallViewModel extends ViewModel {
) )
).pipe(startWith(userSelection ?? "grid")), ).pipe(startWith(userSelection ?? "grid")),
), ),
) ),
.behavior(this.scope); );
public setGridMode(value: GridMode): void { public setGridMode(value: GridMode): void {
this.gridModeUserSelection$.next(value); this.gridModeUserSelection$.next(value);
@@ -1014,8 +1017,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$: Behavior<LayoutMedia> = this.windowMode$ private readonly layoutMedia$ = this.scope.behavior<LayoutMedia>(
.pipe( this.windowMode$.pipe(
switchMap((windowMode) => { switchMap((windowMode) => {
switch (windowMode) { switch (windowMode) {
case "normal": case "normal":
@@ -1077,8 +1080,8 @@ export class CallViewModel extends ViewModel {
return this.pipLayoutMedia$; return this.pipLayoutMedia$;
} }
}), }),
) ),
.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
@@ -1088,13 +1091,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);
private readonly layoutInternals$: Behavior< private readonly layoutInternals$ = this.scope.behavior<
LayoutScanState & { layout: Layout } LayoutScanState & { layout: Layout }
> = combineLatest([ >(
this.layoutMedia$, combineLatest([
this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()), this.layoutMedia$,
]) this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
.pipe( ]).pipe(
scan< scan<
[LayoutMedia, number], [LayoutMedia, number],
LayoutScanState & { layout: Layout }, LayoutScanState & { layout: Layout },
@@ -1129,29 +1132,29 @@ export class CallViewModel extends ViewModel {
}, },
{ layout: null, tiles: TileStore.empty() }, { layout: null, tiles: TileStore.empty() },
), ),
) ),
.behavior(this.scope); );
/** /**
* The layout of tiles in the call interface. * The layout of tiles in the call interface.
*/ */
public readonly layout$: Behavior<Layout> = this.layoutInternals$ public readonly layout$ = this.scope.behavior<Layout>(
.pipe(map(({ layout }) => layout)) this.layoutInternals$.pipe(map(({ layout }) => layout)),
.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$: Behavior<number> = this.layoutInternals$ public readonly tileStoreGeneration$ = this.scope.behavior<number>(
.pipe(map(({ tiles }) => tiles.generation)) this.layoutInternals$.pipe(map(({ tiles }) => tiles.generation)),
.behavior(this.scope); );
public showSpotlightIndicators$: Behavior<boolean> = this.layout$ public showSpotlightIndicators$ = this.scope.behavior<boolean>(
.pipe(map((l) => l.type !== "grid")) this.layout$.pipe(map((l) => l.type !== "grid")),
.behavior(this.scope); );
public showSpeakingIndicators$: Behavior<boolean> = this.layout$ public showSpeakingIndicators$ = this.scope.behavior<boolean>(
.pipe( this.layout$.pipe(
switchMap((l) => { switchMap((l) => {
switch (l.type) { switch (l.type) {
case "spotlight-landscape": case "spotlight-landscape":
@@ -1175,29 +1178,30 @@ export class CallViewModel extends ViewModel {
return of(true); return of(true);
} }
}), }),
) ),
.behavior(this.scope); );
public readonly toggleSpotlightExpanded$: Behavior<(() => void) | null> = public readonly toggleSpotlightExpanded$ = this.scope.behavior<
this.windowMode$ (() => void) | null
.pipe( >(
switchMap((mode) => this.windowMode$.pipe(
mode === "normal" switchMap((mode) =>
? this.layout$.pipe( mode === "normal"
map( ? this.layout$.pipe(
(l) => map(
l.type === "spotlight-landscape" || (l) =>
l.type === "spotlight-expanded", l.type === "spotlight-landscape" ||
), l.type === "spotlight-expanded",
) ),
: of(false), )
), : of(false),
distinctUntilChanged(), ),
map((enabled) => distinctUntilChanged(),
enabled ? (): void => this.spotlightExpandedToggle$.next() : null, map((enabled) =>
), enabled ? (): void => this.spotlightExpandedToggle$.next() : null,
) ),
.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>();
@@ -1232,12 +1236,12 @@ export class CallViewModel extends ViewModel {
this.screenUnhover$.next(); this.screenUnhover$.next();
} }
public readonly showHeader$: Behavior<boolean> = this.windowMode$ public readonly showHeader$ = this.scope.behavior<boolean>(
.pipe(map((mode) => mode !== "pip" && mode !== "flat")) this.windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
.behavior(this.scope); );
public readonly showFooter$: Behavior<boolean> = this.windowMode$ public readonly showFooter$ = this.scope.behavior<boolean>(
.pipe( this.windowMode$.pipe(
switchMap((mode) => { switchMap((mode) => {
switch (mode) { switch (mode) {
case "pip": case "pip":
@@ -1288,20 +1292,23 @@ export class CallViewModel extends ViewModel {
); );
} }
}), }),
) ),
.behavior(this.scope); );
/** /**
* Whether audio is currently being output through the earpiece. * Whether audio is currently being output through the earpiece.
*/ */
public readonly earpieceMode$: Behavior<boolean> = combineLatest( public readonly earpieceMode$ = this.scope.behavior<boolean>(
[ combineLatest(
this.mediaDevices.audioOutput.available$, [
this.mediaDevices.audioOutput.selected$, this.mediaDevices.audioOutput.available$,
], this.mediaDevices.audioOutput.selected$,
(available, selected) => ],
selected !== undefined && available.get(selected.id)?.type === "earpiece", (available, selected) =>
).behavior(this.scope); selected !== undefined &&
available.get(selected.id)?.type === "earpiece",
),
);
/** /**
* Callback to toggle between the earpiece and the loudspeaker. * Callback to toggle between the earpiece and the loudspeaker.
@@ -1309,38 +1316,40 @@ export class CallViewModel extends ViewModel {
* This will be `null` in case the target does not exist in the list * This will be `null` in case the target does not exist in the list
* of available audio outputs. * of available audio outputs.
*/ */
public readonly audioOutputSwitcher$: Behavior<{ public readonly audioOutputSwitcher$ = this.scope.behavior<{
targetOutput: "earpiece" | "speaker"; targetOutput: "earpiece" | "speaker";
switch: () => void; switch: () => void;
} | null> = combineLatest( } | null>(
[ combineLatest(
this.mediaDevices.audioOutput.available$, [
this.mediaDevices.audioOutput.selected$, this.mediaDevices.audioOutput.available$,
], this.mediaDevices.audioOutput.selected$,
(available, selected) => { ],
const selectionType = selected && available.get(selected.id)?.type; (available, selected) => {
const selectionType = selected && available.get(selected.id)?.type;
// If we are in any output mode other than spaeker switch to speaker. // If we are in any output mode other than spaeker switch to speaker.
const newSelectionType: "earpiece" | "speaker" = const newSelectionType: "earpiece" | "speaker" =
selectionType === "speaker" ? "earpiece" : "speaker"; selectionType === "speaker" ? "earpiece" : "speaker";
const newSelection = [...available].find( const newSelection = [...available].find(
([, d]) => d.type === newSelectionType, ([, d]) => d.type === newSelectionType,
); );
if (newSelection === undefined) return null; if (newSelection === undefined) return null;
const [id] = newSelection; const [id] = newSelection;
return { return {
targetOutput: newSelectionType, targetOutput: newSelectionType,
switch: () => this.mediaDevices.audioOutput.select(id), switch: (): void => this.mediaDevices.audioOutput.select(id),
}; };
}, },
).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$ public readonly visibleReactions$ = this.scope.behavior(
.pipe( showReactions.value$.pipe(
switchMap((show) => (show ? this.reactions$ : of({}))), switchMap((show) => (show ? this.reactions$ : of({}))),
scan< scan<
Record<string, ReactionOption>, Record<string, ReactionOption>,
@@ -1355,8 +1364,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

@@ -106,8 +106,8 @@ function availableRawDevices$(
const devices$ = createMediaDeviceObserver(kind, logError, false); const devices$ = createMediaDeviceObserver(kind, logError, false);
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true); const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
return usingNames$ return scope.behavior(
.pipe( usingNames$.pipe(
switchMap((withNames) => switchMap((withNames) =>
withNames withNames
? // It might be that there is already a media stream running somewhere, ? // It might be that there is already a media stream running somewhere,
@@ -123,8 +123,8 @@ function availableRawDevices$(
: devices$, : devices$,
), ),
startWith([]), startWith([]),
) ),
.behavior(scope); );
} }
function buildDeviceMap( function buildDeviceMap(
@@ -165,15 +165,12 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> = private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
availableRawDevices$("audioinput", this.usingNames$, this.scope); availableRawDevices$("audioinput", this.usingNames$, this.scope);
public readonly available$ = this.availableRaw$ public readonly available$ = this.scope.behavior(
.pipe(map(buildDeviceMap)) this.availableRaw$.pipe(map(buildDeviceMap)),
.behavior(this.scope); );
public readonly selected$ = selectDevice$( public readonly selected$ = this.scope.behavior(
this.available$, selectDevice$(this.available$, audioInputSetting.value$).pipe(
audioInputSetting.value$,
)
.pipe(
map((id) => map((id) =>
id === undefined id === undefined
? undefined ? undefined
@@ -191,8 +188,8 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
), ),
}, },
), ),
) ),
.behavior(this.scope); );
public select(id: string): void { public select(id: string): void {
audioInputSetting.setValue(id); audioInputSetting.setValue(id);
@@ -211,12 +208,8 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
class AudioOutput class AudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice> implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{ {
public readonly available$ = availableRawDevices$( public readonly available$ = this.scope.behavior(
"audiooutput", availableRawDevices$("audiooutput", this.usingNames$, this.scope).pipe(
this.usingNames$,
this.scope,
)
.pipe(
map((availableRaw) => { map((availableRaw) => {
const available: Map<string, AudioOutputDeviceLabel> = const available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw); buildDeviceMap(availableRaw);
@@ -233,14 +226,11 @@ class AudioOutput
// automatically track the default device. // automatically track the default device.
return available; return available;
}), }),
) ),
.behavior(this.scope); );
public readonly selected$ = selectDevice$( public readonly selected$ = this.scope.behavior(
this.available$, selectDevice$(this.available$, audioOutputSetting.value$).pipe(
audioOutputSetting.value$,
)
.pipe(
map((id) => map((id) =>
id === undefined id === undefined
? undefined ? undefined
@@ -249,8 +239,8 @@ class AudioOutput
virtualEarpiece: false, virtualEarpiece: false,
}, },
), ),
) ),
.behavior(this.scope); );
public select(id: string): void { public select(id: string): void {
audioOutputSetting.setValue(id); audioOutputSetting.setValue(id);
} }
@@ -268,30 +258,32 @@ class AudioOutput
class ControlledAudioOutput class ControlledAudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice> implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{ {
public readonly available$ = combineLatest( public readonly available$ = this.scope.behavior(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$], combineLatest(
(availableRaw, iosDeviceMenu) => { [controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
const available = new Map<string, AudioOutputDeviceLabel>( (availableRaw, iosDeviceMenu) => {
availableRaw.map( const available = new Map<string, AudioOutputDeviceLabel>(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => { availableRaw.map(
let deviceLabel: AudioOutputDeviceLabel; ({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
// if (isExternalHeadset) // Do we want this? let deviceLabel: AudioOutputDeviceLabel;
if (isEarpiece) deviceLabel = { type: "earpiece" }; // if (isExternalHeadset) // Do we want this?
else if (isSpeaker) deviceLabel = { type: "speaker" }; if (isEarpiece) deviceLabel = { type: "earpiece" };
else deviceLabel = { type: "name", name }; else if (isSpeaker) deviceLabel = { type: "speaker" };
return [id, deviceLabel]; else deviceLabel = { type: "name", name };
}, return [id, deviceLabel];
), },
); ),
);
// Create a virtual earpiece device in case a non-earpiece device is // Create a virtual earpiece device in case a non-earpiece device is
// designated for this purpose // designated for this purpose
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece)) if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" }); available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
return available; return available;
}, },
).behavior(this.scope); ),
);
private readonly deviceSelection$ = new Subject<string>(); private readonly deviceSelection$ = new Subject<string>();
@@ -299,21 +291,23 @@ class ControlledAudioOutput
this.deviceSelection$.next(id); this.deviceSelection$.next(id);
} }
public readonly selected$ = combineLatest( public readonly selected$ = this.scope.behavior(
[ combineLatest(
this.available$, [
merge( this.available$,
controlledOutputSelection$.pipe(startWith(undefined)), merge(
this.deviceSelection$, controlledOutputSelection$.pipe(startWith(undefined)),
), this.deviceSelection$,
], ),
(available, preferredId) => { ],
const id = preferredId ?? available.keys().next().value; (available, preferredId) => {
return id === undefined const id = preferredId ?? available.keys().next().value;
? undefined return id === undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; ? undefined
}, : { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
).behavior(this.scope); },
),
);
public constructor(private readonly scope: ObservableScope) { public constructor(private readonly scope: ObservableScope) {
this.selected$.subscribe((device) => { this.selected$.subscribe((device) => {
@@ -335,19 +329,16 @@ class ControlledAudioOutput
} }
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> { class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
public readonly available$ = availableRawDevices$( public readonly available$ = this.scope.behavior(
"videoinput", availableRawDevices$("videoinput", this.usingNames$, this.scope).pipe(
this.usingNames$, map(buildDeviceMap),
this.scope, ),
) );
.pipe(map(buildDeviceMap)) public readonly selected$ = this.scope.behavior(
.behavior(this.scope); selectDevice$(this.available$, videoInputSetting.value$).pipe(
public readonly selected$ = selectDevice$( map((id) => (id === undefined ? undefined : { id })),
this.available$, ),
videoInputSetting.value$, );
)
.pipe(map((id) => (id === undefined ? undefined : { id })))
.behavior(this.scope);
public select(id: string): void { public select(id: string): void {
videoInputSetting.setValue(id); videoInputSetting.setValue(id);
} }
@@ -381,12 +372,12 @@ export class MediaDevices {
// you to do to receive device names in lieu of a more explicit permissions // you to do to receive device names in lieu of a more explicit permissions
// API. This flag never resets to false, because once permissions are granted // API. This flag never resets to false, because once permissions are granted
// the first time, the user won't be prompted again until reload of the page. // the first time, the user won't be prompted again until reload of the page.
private readonly usingNames$ = this.deviceNamesRequest$ private readonly usingNames$ = this.scope.behavior(
.pipe( this.deviceNamesRequest$.pipe(
map(() => true), map(() => true),
startWith(false), startWith(false),
) ),
.behavior(this.scope); );
public readonly audioInput: MediaDevice< public readonly audioInput: MediaDevice<
DeviceLabel, DeviceLabel,
SelectedAudioInputDevice SelectedAudioInputDevice

View File

@@ -232,13 +232,13 @@ abstract class BaseMediaViewModel extends ViewModel {
private observeTrackReference$( private observeTrackReference$(
source: Track.Source, source: Track.Source,
): Behavior<TrackReferenceOrPlaceholder | undefined> { ): Behavior<TrackReferenceOrPlaceholder | undefined> {
return this.participant$ return this.scope.behavior(
.pipe( this.participant$.pipe(
switchMap((p) => switchMap((p) =>
p === undefined ? of(undefined) : observeTrackReference$(p, source), p === undefined ? of(undefined) : observeTrackReference$(p, source),
), ),
) ),
.behavior(this.scope); );
} }
public constructor( public constructor(
@@ -269,16 +269,18 @@ abstract class BaseMediaViewModel extends ViewModel {
const audio$ = this.observeTrackReference$(audioSource); const audio$ = this.observeTrackReference$(audioSource);
this.video$ = this.observeTrackReference$(videoSource); this.video$ = this.observeTrackReference$(videoSource);
this.unencryptedWarning$ = combineLatest( this.unencryptedWarning$ = this.scope.behavior(
[audio$, this.video$], combineLatest(
(a, v) => [audio$, this.video$],
encryptionSystem.kind !== E2eeType.NONE && (a, v) =>
(a?.publication?.isEncrypted === false || encryptionSystem.kind !== E2eeType.NONE &&
v?.publication?.isEncrypted === false), (a?.publication?.isEncrypted === false ||
).behavior(this.scope); v?.publication?.isEncrypted === false),
),
);
this.encryptionStatus$ = this.participant$ this.encryptionStatus$ = this.scope.behavior(
.pipe( this.participant$.pipe(
switchMap((participant): Observable<EncryptionStatus> => { switchMap((participant): Observable<EncryptionStatus> => {
if (!participant) { if (!participant) {
return of(EncryptionStatus.Connecting); return of(EncryptionStatus.Connecting);
@@ -338,8 +340,8 @@ abstract class BaseMediaViewModel extends ViewModel {
); );
} }
}), }),
) ),
.behavior(this.scope); );
} }
} }
@@ -358,8 +360,8 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking$ = this.participant$ public readonly speaking$ = this.scope.behavior(
.pipe( this.participant$.pipe(
switchMap((p) => switchMap((p) =>
p p
? observeParticipantEvents( ? observeParticipantEvents(
@@ -368,8 +370,8 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
).pipe(map((p) => p.isSpeaking)) ).pipe(map((p) => p.isSpeaking))
: of(false), : of(false),
), ),
) ),
.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).
@@ -407,17 +409,17 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
displayName$, displayName$,
); );
const media$ = participant$ const media$ = this.scope.behavior(
.pipe( participant$.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
) ),
.behavior(this.scope); );
this.audioEnabled$ = media$ this.audioEnabled$ = this.scope.behavior(
.pipe(map((m) => m?.microphoneTrack?.isMuted === false)) media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
.behavior(this.scope); );
this.videoEnabled$ = media$ this.videoEnabled$ = this.scope.behavior(
.pipe(map((m) => m?.cameraTrack?.isMuted === false)) media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
.behavior(this.scope); );
} }
public toggleFitContain(): void { public toggleFitContain(): void {
@@ -443,8 +445,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether the video should be mirrored. * Whether the video should be mirrored.
*/ */
public readonly mirror$ = this.video$ public readonly mirror$ = this.scope.behavior(
.pipe( 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);
@@ -455,8 +457,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
map(() => facingModeFromLocalTrack(track).facingMode === "user"), map(() => facingModeFromLocalTrack(track).facingMode === "user"),
); );
}), }),
) ),
.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
@@ -520,12 +522,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$: Behavior<number> = merge( public readonly localVolume$ = this.scope.behavior<number>(
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), merge(
this.localVolumeAdjustment$, this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
this.localVolumeCommit$.pipe(map(() => "commit" as const)), this.localVolumeAdjustment$,
) 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":
@@ -548,15 +550,15 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
} }
}), }),
map(({ volume }) => volume), map(({ volume }) => volume),
) ),
.behavior(this.scope); );
/** /**
* Whether this participant's audio is disabled. * Whether this participant's audio is disabled.
*/ */
public readonly locallyMuted$: Behavior<boolean> = this.localVolume$ public readonly locallyMuted$ = this.scope.behavior<boolean>(
.pipe(map((volume) => volume === 0)) this.localVolume$.pipe(map((volume) => volume === 0)),
.behavior(this.scope); );
public constructor( public constructor(
id: string, id: string,

View File

@@ -10,12 +10,13 @@ import { combineLatest, startWith } from "rxjs";
import { setAudioEnabled$ } from "../controls"; import { setAudioEnabled$ } from "../controls";
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings"; import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
import { globalScope } from "./ObservableScope"; import { globalScope } from "./ObservableScope";
import "../state/Behavior"; // Patches in the Observable.behavior method
/** /**
* This can transition into sth more complete: `GroupCallViewModel.ts` * This can transition into sth more complete: `GroupCallViewModel.ts`
*/ */
export const muteAllAudio$ = combineLatest( export const muteAllAudio$ = globalScope.behavior(
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$], combineLatest(
(outputEnabled, settingsMute) => !outputEnabled || settingsMute, [setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
).behavior(globalScope); (outputEnabled, settingsMute) => !outputEnabled || settingsMute,
),
);

View File

@@ -5,10 +5,20 @@ 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 { type Observable, Subject, takeUntil } from "rxjs"; import {
BehaviorSubject,
distinctUntilChanged,
type Observable,
Subject,
takeUntil,
} from "rxjs";
import { type Behavior } from "./Behavior";
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>; type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
const nothing = Symbol("nothing");
/** /**
* A scope which limits the execution lifetime of its bound Observables. * A scope which limits the execution lifetime of its bound Observables.
*/ */
@@ -25,6 +35,33 @@ export class ObservableScope {
return this.bindImpl; return this.bindImpl;
} }
/**
* Converts an Observable to a Behavior. If no initial value is specified, the
* Observable must synchronously emit an initial value.
*/
public behavior<T>(
setValue$: Observable<T>,
initialValue: T | typeof nothing = nothing,
): Behavior<T> {
const subject$ = new BehaviorSubject(initialValue);
// Push values from the Observable into the BehaviorSubject.
// BehaviorSubjects have an undesirable feature where if you call 'complete',
// they will no longer re-emit their current value upon subscription. We want
// to support Observables that complete (for example `of({})`), so we have to
// take care to not propagate the completion event.
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
next(value) {
subject$.next(value);
},
error(err: unknown) {
subject$.error(err);
},
});
if (subject$.value === nothing)
throw new Error("Behavior failed to synchronously emit an initial value");
return subject$ as Behavior<T>;
}
/** /**
* Ends the scope, causing any bound Observables to complete. * Ends the scope, causing any bound Observables to complete.
*/ */

View File

@@ -124,10 +124,11 @@ export function withTestScheduler(
const initialValue = const initialValue =
values === undefined ? (initialMarble as T) : values[initialMarble]; values === undefined ? (initialMarble as T) : values[initialMarble];
// The remainder of the marble diagram should start on frame 1 // The remainder of the marble diagram should start on frame 1
return helpers return scope.behavior(
.hot(`-${marbles.slice(initialMarbleIndex + 1)}`, values, error) helpers
.pipe(startWith(initialValue)) .hot(`-${marbles.slice(initialMarbleIndex + 1)}`, values, error)
.behavior(scope); .pipe(startWith(initialValue)),
);
}, },
}), }),
); );