Replace ObservableScope.state with Observable.behavior
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user