Skip to content

Commit 496fe86

Browse files
committed
Add camera switching to the media view model
1 parent d4f451a commit 496fe86

File tree

6 files changed

+85
-12
lines changed

6 files changed

+85
-12
lines changed

src/room/InCallView.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import { useTypedEventEmitter } from "../useEvents.ts";
103103
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
104104
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
105105
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
106+
import { useMediaDevices } from "../MediaDevicesContext.ts";
106107

107108
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
108109

@@ -114,6 +115,7 @@ export interface ActiveCallProps
114115
}
115116

116117
export const ActiveCall: FC<ActiveCallProps> = (props) => {
118+
const mediaDevices = useMediaDevices();
117119
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
118120
const { livekitRoom, connState } = useLivekit(
119121
props.rtcSession,
@@ -154,6 +156,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
154156
const vm = new CallViewModel(
155157
props.rtcSession,
156158
livekitRoom,
159+
mediaDevices,
157160
props.e2eeSystem,
158161
connStateObservable$,
159162
reactionsReader.raisedHands$,
@@ -165,7 +168,13 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
165168
reactionsReader.destroy();
166169
};
167170
}
168-
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
171+
}, [
172+
props.rtcSession,
173+
livekitRoom,
174+
mediaDevices,
175+
props.e2eeSystem,
176+
connStateObservable$,
177+
]);
169178

170179
if (livekitRoom === undefined || vm === null) return null;
171180

src/state/CallViewModel.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
localId,
7272
localRtcMember,
7373
} from "../utils/test-fixtures";
74+
import { type MediaDevices } from "./MediaDevices";
7475

7576
vi.mock("@livekit/components-core");
7677

@@ -262,6 +263,7 @@ function withCallViewModel(
262263
const vm = new CallViewModel(
263264
rtcSession as unknown as MatrixRTCSession,
264265
liveKitRoom,
266+
{} as unknown as MediaDevices,
265267
{
266268
kind: E2eeType.PER_PARTICIPANT,
267269
},

src/state/CallViewModel.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
import { observeSpeaker$ } from "./observeSpeaker";
9191
import { shallowEquals } from "../utils/array";
9292
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
93+
import { type MediaDevices } from "./MediaDevices";
9394

9495
// How long we wait after a focus switch before showing the real participant
9596
// list again
@@ -254,6 +255,7 @@ class UserMedia {
254255
participant: LocalParticipant | RemoteParticipant | undefined,
255256
encryptionSystem: EncryptionSystem,
256257
livekitRoom: LivekitRoom,
258+
mediaDevices: MediaDevices,
257259
displayname$: Observable<string>,
258260
handRaised$: Observable<Date | null>,
259261
reaction$: Observable<ReactionOption | null>,
@@ -267,6 +269,7 @@ class UserMedia {
267269
this.participant$.asObservable() as Observable<LocalParticipant>,
268270
encryptionSystem,
269271
livekitRoom,
272+
mediaDevices,
270273
displayname$,
271274
handRaised$,
272275
reaction$,
@@ -564,6 +567,7 @@ export class CallViewModel extends ViewModel {
564567
participant,
565568
this.encryptionSystem,
566569
this.livekitRoom,
570+
this.mediaDevices,
567571
this.memberDisplaynames$.pipe(
568572
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
569573
),
@@ -628,6 +632,7 @@ export class CallViewModel extends ViewModel {
628632
participant,
629633
this.encryptionSystem,
630634
this.livekitRoom,
635+
this.mediaDevices,
631636
this.memberDisplaynames$.pipe(
632637
map((m) => m.get(participant.identity) ?? "[👻]"),
633638
),
@@ -1322,6 +1327,7 @@ export class CallViewModel extends ViewModel {
13221327
// A call is permanently tied to a single Matrix room and LiveKit room
13231328
private readonly matrixRTCSession: MatrixRTCSession,
13241329
private readonly livekitRoom: LivekitRoom,
1330+
private readonly mediaDevices: MediaDevices,
13251331
private readonly encryptionSystem: EncryptionSystem,
13261332
private readonly connectionState$: Observable<ECConnectionState>,
13271333
private readonly handsRaisedSubject$: Observable<

src/state/MediaViewModel.ts

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import {
1717
type LocalParticipant,
1818
LocalTrack,
19+
LocalVideoTrack,
1920
type Participant,
2021
ParticipantEvent,
2122
type RemoteParticipant,
@@ -27,6 +28,7 @@ import {
2728
RemoteTrack,
2829
} from "livekit-client";
2930
import { type RoomMember } from "matrix-js-sdk";
31+
import { logger } from "matrix-js-sdk/lib/logger";
3032
import {
3133
BehaviorSubject,
3234
type Observable,
@@ -51,6 +53,8 @@ import { accumulate } from "../utils/observable";
5153
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
5254
import { E2eeType } from "../e2ee/e2eeType";
5355
import { type ReactionOption } from "../reactions";
56+
import { platform } from "../Platform";
57+
import { type MediaDevices } from "./MediaDevices";
5458

5559
export function observeTrackReference$(
5660
participant$: Observable<Participant | undefined>,
@@ -433,20 +437,35 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
433437
* The local participant's user media.
434438
*/
435439
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
440+
/**
441+
* The local video track as an observable that emits whenever the track
442+
* changes, the camera is switched, or the track is muted.
443+
*/
444+
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
445+
this.video$.pipe(
446+
switchMap((v) => {
447+
const track = v?.publication?.track;
448+
if (!(track instanceof LocalVideoTrack)) return of(null);
449+
return merge(
450+
// Watch for track restarts because they indicate a camera switch
451+
fromEvent(track, TrackEvent.Restarted).pipe(
452+
startWith(null),
453+
map(() => track),
454+
),
455+
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
456+
);
457+
}),
458+
);
459+
436460
/**
437461
* Whether the video should be mirrored.
438462
*/
439-
public readonly mirror$ = this.video$.pipe(
440-
switchMap((v) => {
441-
const track = v?.publication?.track;
442-
if (!(track instanceof LocalTrack)) return of(false);
443-
// Watch for track restarts, because they indicate a camera switch
444-
return fromEvent(track, TrackEvent.Restarted).pipe(
445-
startWith(null),
446-
// Mirror only front-facing cameras (those that face the user)
447-
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
448-
);
449-
}),
463+
public readonly mirror$ = this.videoTrack$.pipe(
464+
// Mirror only front-facing cameras (those that face the user)
465+
map(
466+
(track) =>
467+
track !== null && facingModeFromLocalTrack(track).facingMode === "user",
468+
),
450469
this.scope.state(),
451470
);
452471

@@ -457,12 +476,46 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
457476
public readonly alwaysShow$ = alwaysShowSelf.value$;
458477
public readonly setAlwaysShow = alwaysShowSelf.setValue;
459478

479+
/**
480+
* Callback for switching between the front and back cameras.
481+
*/
482+
public readonly switchCamera$: Observable<(() => void) | null> =
483+
platform === "desktop"
484+
? of(null)
485+
: this.videoTrack$.pipe(
486+
map((track) => {
487+
if (track === null) return null;
488+
const facingMode = facingModeFromLocalTrack(track).facingMode;
489+
// If the camera isn't front or back-facing, don't provide a switch
490+
// camera shortcut at all
491+
if (facingMode !== "user" && facingMode !== "environment")
492+
return null;
493+
// Restart the track with a camera facing the opposite direction
494+
return (): void =>
495+
void track
496+
.restartTrack({
497+
facingMode: facingMode === "user" ? "environment" : "user",
498+
})
499+
.then(() => {
500+
// Inform the MediaDevices which camera was chosen
501+
const deviceId =
502+
track.mediaStreamTrack.getSettings().deviceId;
503+
if (deviceId !== undefined)
504+
this.mediaDevices.videoInput.select(deviceId);
505+
})
506+
.catch((e) =>
507+
logger.error("Failed to switch camera", facingMode, e),
508+
);
509+
}),
510+
);
511+
460512
public constructor(
461513
id: string,
462514
member: RoomMember | undefined,
463515
participant$: Observable<LocalParticipant | undefined>,
464516
encryptionSystem: EncryptionSystem,
465517
livekitRoom: LivekitRoom,
518+
private readonly mediaDevices: MediaDevices,
466519
displayname$: Observable<string>,
467520
handRaised$: Observable<Date | null>,
468521
reaction$: Observable<ReactionOption | null>,

src/utils/test-viewmodel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
localRtcMember,
2727
} from "./test-fixtures";
2828
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
29+
import { type MediaDevices } from "../state/MediaDevices";
2930

3031
export function getBasicRTCSession(
3132
members: RoomMember[],
@@ -132,6 +133,7 @@ export function getBasicCallViewModelEnvironment(
132133
const vm = new CallViewModel(
133134
rtcSession as unknown as MatrixRTCSession,
134135
liveKitRoom,
136+
{} as unknown as MediaDevices,
135137
{
136138
kind: E2eeType.PER_PARTICIPANT,
137139
},

src/utils/test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export async function withLocalMedia(
216216
kind: E2eeType.PER_PARTICIPANT,
217217
},
218218
mockLivekitRoom({ localParticipant }),
219+
{} as unknown as MediaDevices,
219220
of(roomMember.rawDisplayName ?? "nodisplayname"),
220221
of(null),
221222
of(null),

0 commit comments

Comments
 (0)