Move 'behavior' to be a method on ObservableScope
This commit is contained in:
@@ -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>;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user