FEATURE: Restore preferred voice camera (#43252)

Remember explicit camera choices and restore the camera when a voice
call becomes visible or screen sharing ends. Guard automatic restoration
against stale captures, room changes, permissions, and concurrent user
actions.
This commit is contained in:
Sam
2026-09-04 15:15:33 -03:00
committed by GitHub
parent 07b05ee8cd
commit fe397de512
4 changed files with 938 additions and 25 deletions
@@ -61,8 +61,11 @@ import {
import TranscriptionCoordinator from "../../lib/voice/transcription-coordinator";
import { applyVoiceQuality } from "../../lib/voice/video-quality";
const CAMERA_ENABLED_KEY_PREFIX = "voice-camera-enabled";
export default class VoiceWebrtcService extends Service {
@service currentUser;
@service keyValueStore;
@service messageBus;
@service modal;
@service siteSettings;
@@ -111,6 +114,8 @@ export default class VoiceWebrtcService extends Service {
#roomHandlerCallbacks = new Map();
#deferredTeardownTimers = new Set();
#pendingPlaybackElements = new WeakSet();
#cameraRestorePromise = null;
#queuedCameraRestoreRoomId = null;
#signaling;
#peerManager;
@@ -286,6 +291,11 @@ export default class VoiceWebrtcService extends Service {
this.currentUser?.id,
state
),
onScreenShareEnded: () => {
if (this.watchingRoomId) {
this.#restorePreferredCamera(this.watchingRoomId);
}
},
showError: (messageKey) =>
this.toasts.error({
duration: 5000,
@@ -419,6 +429,9 @@ export default class VoiceWebrtcService extends Service {
this.#livekit.destroy();
this.#signaling.destroy();
this.watchingRoomId = null;
this.#cameraRestorePromise = null;
this.#queuedCameraRestoreRoomId = null;
this.#localVideo.destroy();
this.#localAudio.stop();
this.#transcription.destroy();
@@ -634,6 +647,86 @@ export default class VoiceWebrtcService extends Service {
return (this.#roomTransports.get(roomId) ?? "mesh") === "mesh";
}
#cameraEnabledKey(userId) {
return `${CAMERA_ENABLED_KEY_PREFIX}-${userId}`;
}
#cameraPreferred(userId = this.currentUser?.id) {
if (!userId) {
return false;
}
try {
return this.keyValueStore.get(this.#cameraEnabledKey(userId)) === "true";
} catch {
return false;
}
}
#setCameraPreferred(enabled, userId = this.currentUser?.id) {
if (!userId) {
return;
}
try {
if (enabled) {
this.keyValueStore.set({
key: this.#cameraEnabledKey(userId),
value: "true",
});
} else {
this.keyValueStore.remove(this.#cameraEnabledKey(userId));
}
} catch {
// A storage failure must not interfere with the camera control.
}
}
#restorePreferredCamera(roomId) {
const userId = this.currentUser?.id;
if (
this.#cameraRestorePromise ||
!this.#activeRoomIds.has(roomId) ||
this.watchingRoomId !== roomId ||
this.localVideoKind ||
!this.#cameraPreferred(userId) ||
!this.canPublishVideo(roomId)
) {
return;
}
const restorePromise = this.#localVideo
.start("camera", {
silent: true,
shouldContinue: () =>
this.currentUser?.id === userId &&
this.#activeRoomIds.has(roomId) &&
this.watchingRoomId === roomId &&
(!this.localVideoKind || this.localVideoKind === "camera") &&
this.#cameraPreferred(userId) &&
this.canPublishVideo(roomId),
})
.catch((error) => {
// eslint-disable-next-line no-console
console.warn("[voice] failed to restore preferred camera state", error);
});
this.#cameraRestorePromise = restorePromise;
restorePromise.finally(() => {
if (this.#cameraRestorePromise === restorePromise) {
this.#cameraRestorePromise = null;
}
const queuedRoomId = this.#queuedCameraRestoreRoomId;
this.#queuedCameraRestoreRoomId = null;
if (queuedRoomId && this.watchingRoomId === queuedRoomId) {
this.#restorePreferredCamera(queuedRoomId);
} else if (this.watchingRoomId && this.watchingRoomId !== roomId) {
this.#restorePreferredCamera(this.watchingRoomId);
}
});
}
isLivekitRoom(roomId) {
return this.#roomTransports.get(roomId) === "livekit";
}
@@ -1224,12 +1317,41 @@ export default class VoiceWebrtcService extends Service {
return this.#localVideo.inputDeviceId;
}
toggleCamera() {
return this.#localVideo.toggleCamera();
async toggleCamera() {
const userId = this.currentUser?.id;
if (!this.localVideoKind && this.#cameraRestorePromise) {
this.#setCameraPreferred(false, userId);
this.#queuedCameraRestoreRoomId = null;
await this.#cameraRestorePromise;
if (this.localVideoKind === "camera") {
await this.#localVideo.stop();
}
return;
}
const cameraWasActive = this.localVideoKind === "camera";
if (cameraWasActive) {
this.#setCameraPreferred(false, userId);
}
const result = await this.#localVideo.toggleCamera();
if (!cameraWasActive && this.localVideoKind === "camera") {
this.#setCameraPreferred(true, userId);
}
return result;
}
toggleScreenShare() {
return this.#localVideo.toggleScreenShare();
async toggleScreenShare() {
const screenWasActive = this.localVideoKind === "screen";
const result = await this.#localVideo.toggleScreenShare();
if (screenWasActive && !this.localVideoKind && this.watchingRoomId) {
this.#restorePreferredCamera(this.watchingRoomId);
}
return result;
}
toggleVideoBlur() {
@@ -1304,6 +1426,15 @@ export default class VoiceWebrtcService extends Service {
if (!this.#isMeshRoom(roomId)) {
this.#livekit.sessionFor(roomId)?.setVideoSubscriptionsEnabled(watching);
}
if (watching) {
if (this.#cameraRestorePromise) {
this.#queuedCameraRestoreRoomId = roomId;
}
this.#restorePreferredCamera(roomId);
} else if (this.#queuedCameraRestoreRoomId === roomId) {
this.#queuedCameraRestoreRoomId = null;
}
}
@action
@@ -52,6 +52,7 @@ export default class LocalVideoManager {
#isBlurAllowed;
#setParticipantVideoState;
#showError;
#onScreenShareEnded;
constructor(options) {
this.#peerManager = options.peerManager;
@@ -71,6 +72,7 @@ export default class LocalVideoManager {
this.#isBlurAllowed = options.isBlurAllowed;
this.#setParticipantVideoState = options.setParticipantVideoState;
this.#showError = options.showError;
this.#onScreenShareEnded = options.onScreenShareEnded;
}
get blurSupported() {
@@ -207,10 +209,12 @@ export default class LocalVideoManager {
}
}
#revertBlurPreference() {
#revertBlurPreference({ silent = false } = {}) {
this.blurEnabled = false;
BackgroundBlurManager.setPreference(false);
this.#showError("voice.video_settings.blur_failed");
if (!silent) {
this.#showError("voice.video_settings.blur_failed");
}
}
setBlurAmount(value) {
@@ -334,9 +338,6 @@ export default class LocalVideoManager {
}
track.contentHint = "motion";
track.addEventListener("ended", () => this.#handleTrackEnded(), {
once: true,
});
const oldStream = this.stream;
const oldRaw = this.#rawStream;
@@ -369,6 +370,13 @@ export default class LocalVideoManager {
this.#rawStream = blurResult ? newStream : null;
this.stream = outgoingStream;
const swappedEpoch = ++this.#epoch;
track.addEventListener(
"ended",
() => this.#handleTrackEnded(swappedEpoch, "camera"),
{ once: true }
);
oldStream?.getTracks().forEach((streamTrack) => streamTrack.stop());
if (oldRaw && oldRaw !== oldStream) {
oldRaw.getTracks().forEach((streamTrack) => streamTrack.stop());
@@ -414,14 +422,16 @@ export default class LocalVideoManager {
}
}
async start(kind) {
async start(kind, { shouldContinue, silent = false } = {}) {
const roomId = this.#getFirstActiveRoomId();
if (!roomId) {
return;
}
if (!this.#canPublishVideo(roomId)) {
this.#showError("voice.video.publisher_limit");
if (!silent) {
this.#showError("voice.video.publisher_limit");
}
return;
}
@@ -461,14 +471,18 @@ export default class LocalVideoManager {
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`[voice] failed to obtain ${kind} stream`, error);
if (error?.name !== "NotAllowedError" && error?.name !== "AbortError") {
if (
!silent &&
error?.name !== "NotAllowedError" &&
error?.name !== "AbortError"
) {
this.#showError("voice.video.capture_failed");
}
return;
}
// The user may have left the room while the capture picker was open.
if (!this.#isActiveRoom(roomId)) {
if (!this.#isActiveRoom(roomId) || (shouldContinue && !shouldContinue())) {
stream.getTracks().forEach((streamTrack) => streamTrack.stop());
return;
}
@@ -495,7 +509,7 @@ export default class LocalVideoManager {
const epoch = ++this.#epoch;
track.contentHint = kind === "screen" ? "detail" : "motion";
track.addEventListener("ended", () => this.#handleTrackEnded(), {
track.addEventListener("ended", () => this.#handleTrackEnded(epoch, kind), {
once: true,
});
@@ -514,7 +528,11 @@ export default class LocalVideoManager {
) {
const result = await this.#createBackgroundBlur(stream);
if (epoch !== this.#epoch || !this.#isActiveRoom(roomId)) {
if (
epoch !== this.#epoch ||
!this.#isActiveRoom(roomId) ||
(shouldContinue && !shouldContinue())
) {
result?.manager.teardown();
stream.getTracks().forEach((streamTrack) => streamTrack.stop());
return;
@@ -525,7 +543,7 @@ export default class LocalVideoManager {
this.#rawStream = stream;
outgoingStream = result.processed;
} else {
this.#revertBlurPreference();
this.#revertBlurPreference({ silent });
}
}
@@ -536,12 +554,30 @@ export default class LocalVideoManager {
await this.#broadcastState(roomId);
} catch (error) {
await this.stop({ broadcast: false });
popupAjaxError(error);
if (!silent) {
popupAjaxError(error);
}
return;
}
if (!this.#ownsPipeline(epoch, outgoingStream, kind)) {
return;
}
if (shouldContinue && !shouldContinue()) {
await this.stop();
return;
}
await this.syncSenders(roomId);
if (!this.#ownsPipeline(epoch, outgoingStream, kind)) {
return;
}
if (shouldContinue && !shouldContinue()) {
await this.stop();
return;
}
// Applies any blur preference change that raced this startup (e.g. the
// toggle was flipped while the model loaded for the initial wrap).
this.#enqueueOp(() => this.#reconcileBlurOp());
@@ -563,19 +599,31 @@ export default class LocalVideoManager {
if (roomId) {
await this.syncSenders(roomId);
if (broadcast) {
this.#broadcastState(roomId).catch(() => {});
await this.#broadcastState(roomId).catch(() => {});
}
}
}
#handleTrackEnded() {
if (!this.kind) {
#ownsPipeline(epoch, stream, kind) {
return (
epoch === this.#epoch && this.stream === stream && this.kind === kind
);
}
#handleTrackEnded(epoch, endedKind) {
if (epoch !== this.#epoch || endedKind !== this.kind) {
return;
}
this.stop().catch((error) => {
// eslint-disable-next-line no-console
console.warn("[voice] failed to stop local video", error);
});
this.stop()
.then(() => {
if (endedKind === "screen") {
this.#onScreenShareEnded?.();
}
})
.catch((error) => {
// eslint-disable-next-line no-console
console.warn("[voice] failed to stop local video", error);
});
}
#applyContentHint() {
@@ -486,6 +486,9 @@ module("Voice | Unit | Service | voice-webrtc-livekit", function (hooks) {
setPeerTimingForTesting(SAFE_PEER_TIMING);
this.currentUser = logIn(this.owner);
this.currentUser.id = 10;
this.keyValueStore = this.owner.lookup("service:key-value-store");
this.cameraPreferenceKey = `voice-camera-enabled-${this.currentUser.id}`;
this.keyValueStore.remove(this.cameraPreferenceKey);
this.siteSettings = this.owner.lookup("service:site-settings");
this.siteSettings.voice_auto_status_enabled = true;
this.siteSettings.voice_video_enabled = true;
@@ -612,6 +615,7 @@ module("Voice | Unit | Service | voice-webrtc-livekit", function (hooks) {
hooks.afterEach(function () {
this.subject?.leave({ id: 1 }, { keepLocalStream: true });
this.keyValueStore.remove(this.cameraPreferenceKey);
setPeerTimingForTesting(null);
setLivekitSdkLoaderForTesting(null);
@@ -997,6 +1001,35 @@ module("Voice | Unit | Service | voice-webrtc-livekit", function (hooks) {
);
});
test("a remembered camera publishes after the LiveKit call becomes visible", async function (assert) {
this.keyValueStore.set({
key: this.cameraPreferenceKey,
value: "true",
});
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await waitUntil(() =>
this.stateRequests.some(({ video }) => video === "true")
);
const lkRoom = this.FakeLivekitRoom.instances[0];
const publication = lkRoom.localParticipant.published.find(
({ track }) => track.mediaStreamTrack === this.cameraTrack
);
assert.notStrictEqual(
publication,
undefined,
"publishes the remembered camera track"
);
assert.deepEqual(
this.stateRequests.at(-1),
{ video: "true", screen: "false" },
"broadcasts the restored camera state"
);
});
test("toggleCamera publishes a simulcast camera track and broadcasts state", async function (assert) {
await this.subject.join(this.room);
await wait(50);
@@ -99,7 +99,12 @@ class VoiceRoomsStub extends Service {
}
class ToastsStub extends Service {
error() {}
errors = [];
error(options) {
this.errors.push(options);
}
success() {}
default() {}
}
@@ -462,6 +467,9 @@ module("Voice | Unit | Service | voice-webrtc", function (hooks) {
hooks.beforeEach(function () {
setPeerTimingForTesting(SAFE_PEER_TIMING);
this.currentUser = logIn(this.owner);
this.keyValueStore = this.owner.lookup("service:key-value-store");
this.cameraPreferenceKey = `voice-camera-enabled-${this.currentUser.id}`;
this.keyValueStore.remove(this.cameraPreferenceKey);
this.siteSettings = this.owner.lookup("service:site-settings");
this.siteSettings.voice_auto_status_enabled = true;
localStorage.removeItem("voice:noise-suppression");
@@ -547,6 +555,7 @@ module("Voice | Unit | Service | voice-webrtc", function (hooks) {
hooks.afterEach(function () {
this.subject?.leave({ id: 1 }, { keepLocalStream: true });
this.keyValueStore.remove(this.cameraPreferenceKey);
setPeerTimingForTesting(null);
globalThis.RTCPeerConnection = this.originalRTCPeerConnection;
@@ -2184,6 +2193,18 @@ module("Voice | Unit | Service | voice-webrtc", function (hooks) {
};
}
function createEndableCameraTrack(id) {
const track = createFakeCameraTrack(id);
let ended;
track.addEventListener = (event, callback) => {
if (event === "ended") {
ended = callback;
}
};
track.end = () => ended?.();
return track;
}
function createFakeCameraStream(id, track) {
return {
id,
@@ -2206,6 +2227,686 @@ module("Voice | Unit | Service | voice-webrtc", function (hooks) {
pretender.post("/voice/rooms/1/state", () => response({}));
}
test("explicit camera toggles update the remembered preference", async function (assert) {
setupCameraRoom(this);
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
const cameraTrack = createFakeCameraTrack("camera-track");
const cameraStream = createFakeCameraStream("camera-stream", cameraTrack);
navigator.mediaDevices.getUserMedia = async (constraints) =>
constraints?.video ? cameraStream : rawStream;
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await this.subject.toggleCamera();
assert.strictEqual(
this.keyValueStore.get(this.cameraPreferenceKey),
"true",
"remembers a successfully started camera"
);
await this.subject.toggleCamera();
assert.strictEqual(
this.keyValueStore.get(this.cameraPreferenceKey),
undefined,
"clears the preference when the user turns the camera off"
);
} finally {
audioEnvironment.restore();
}
});
test("a remembered camera restarts after leaving and joining another watched room", async function (assert) {
setupCameraRoom(this);
const secondRoom = {
...this.room,
id: 2,
name: "Second room",
active_participants: [{ id: this.currentUser.id, role: "participant" }],
};
this.rooms.seedRoom(secondRoom);
pretender.post("/voice/rooms/2/join", () =>
response({
participant_session_id: "session-def",
room: JSON.parse(JSON.stringify(secondRoom)),
})
);
pretender.post("/voice/rooms/2/state", () => response({}));
pretender.delete("/voice/rooms/2/leave", () => response({}));
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
const cameraTracks = [];
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.video) {
return rawStream;
}
const track = createFakeCameraTrack(
`camera-track-${cameraTracks.length}`
);
cameraTracks.push(track);
return createFakeCameraStream(
`camera-stream-${cameraTracks.length}`,
track
);
};
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await this.subject.toggleCamera();
this.subject.leave(this.room, { keepLocalStream: true });
assert.true(
cameraTracks[0].stopped,
"releases the first room's camera track while preserving intent"
);
await this.subject.join(secondRoom);
this.subject.setWatching(2, true);
await waitUntil(() => this.subject.localVideoKind === "camera");
assert.strictEqual(
cameraTracks.length,
2,
"acquires a fresh camera stream without another camera-button click"
);
assert.strictEqual(
this.keyValueStore.get(this.cameraPreferenceKey),
"true",
"keeps the remembered camera preference"
);
} finally {
this.subject.leave(secondRoom, { keepLocalStream: true });
audioEnvironment.restore();
}
});
test("a remembered camera waits for a visible call surface", async function (assert) {
setupCameraRoom(this);
this.keyValueStore.set({
key: this.cameraPreferenceKey,
value: "true",
});
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
let cameraCaptures = 0;
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.video) {
return rawStream;
}
cameraCaptures++;
const track = createFakeCameraTrack("camera-track");
return createFakeCameraStream("camera-stream", track);
};
try {
await this.subject.join(this.room);
await wait(20);
assert.strictEqual(
cameraCaptures,
0,
"does not capture while the room has no visible controls"
);
this.subject.setWatching(1, true);
await waitUntil(() => this.subject.localVideoKind === "camera");
assert.strictEqual(
cameraCaptures,
1,
"captures after the room page or call widget becomes visible"
);
} finally {
audioEnvironment.restore();
}
});
test("a pending camera restore is deduplicated and canceled when its controls disappear", async function (assert) {
setupCameraRoom(this);
this.keyValueStore.set({
key: this.cameraPreferenceKey,
value: "true",
});
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
const cameraGranted = deferred();
const cameraTrack = createFakeCameraTrack("camera-track");
const cameraStream = createFakeCameraStream("camera-stream", cameraTrack);
let cameraCaptures = 0;
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.video) {
return rawStream;
}
cameraCaptures++;
return cameraGranted.promise;
};
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
this.subject.setWatching(1, true);
await waitUntil(() => cameraCaptures === 1);
this.subject.setWatching(1, false);
cameraGranted.resolve(cameraStream);
await waitUntil(() => cameraTrack.stopped);
assert.strictEqual(
cameraCaptures,
1,
"uses one capture request for repeated watching updates"
);
assert.strictEqual(
this.subject.localVideoKind,
null,
"does not publish after the visible controls disappear"
);
} finally {
audioEnvironment.restore();
}
});
test("an in-flight camera restore does not replace an explicit screen share", async function (assert) {
setupCameraRoom(this);
this.keyValueStore.set({
key: this.cameraPreferenceKey,
value: "true",
});
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
const cameraGranted = deferred();
const cameraTrack = createFakeCameraTrack("camera-track");
const cameraStream = createFakeCameraStream("camera-stream", cameraTrack);
const screenTrack = createFakeCameraTrack("screen-track");
const screenStream = {
...createFakeCameraStream("screen-stream", screenTrack),
getAudioTracks: () => [],
};
const originalGetDisplayMedia = navigator.mediaDevices.getDisplayMedia;
navigator.mediaDevices.getUserMedia = async (constraints) =>
constraints?.video ? cameraGranted.promise : rawStream;
navigator.mediaDevices.getDisplayMedia = async () => screenStream;
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await this.subject.toggleScreenShare();
cameraGranted.resolve(cameraStream);
await waitUntil(() => cameraTrack.stopped);
assert.strictEqual(
this.subject.localVideoKind,
"screen",
"keeps the explicitly selected screen share active"
);
assert.false(
screenTrack.stopped,
"does not stop the screen track when camera capture resolves"
);
} finally {
if (originalGetDisplayMedia) {
navigator.mediaDevices.getDisplayMedia = originalGetDisplayMedia;
} else {
delete navigator.mediaDevices.getDisplayMedia;
}
audioEnvironment.restore();
}
});
test("stopping a screen share restores the previously enabled camera", async function (assert) {
setupCameraRoom(this);
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
const cameraTracks = [];
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.video) {
return rawStream;
}
const track = createFakeCameraTrack(
`camera-track-${cameraTracks.length}`
);
cameraTracks.push(track);
return createFakeCameraStream(
`camera-stream-${cameraTracks.length}`,
track
);
};
const screenTrack = createFakeCameraTrack("screen-track");
const screenStream = {
...createFakeCameraStream("screen-stream", screenTrack),
getAudioTracks: () => [],
};
const originalGetDisplayMedia = navigator.mediaDevices.getDisplayMedia;
navigator.mediaDevices.getDisplayMedia = async () => screenStream;
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await this.subject.toggleCamera();
await this.subject.toggleScreenShare();
assert.strictEqual(
this.subject.localVideoKind,
"screen",
"replaces the camera with the screen share"
);
await this.subject.toggleScreenShare();
await waitUntil(() => this.subject.localVideoKind === "camera");
assert.strictEqual(
cameraTracks.length,
2,
"reacquires the camera after screen sharing stops"
);
} finally {
if (originalGetDisplayMedia) {
navigator.mediaDevices.getDisplayMedia = originalGetDisplayMedia;
} else {
delete navigator.mediaDevices.getDisplayMedia;
}
audioEnvironment.restore();
}
});
test("stopping a screen share keeps a previously disabled camera off", async function (assert) {
setupCameraRoom(this);
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
let cameraCaptures = 0;
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (constraints?.video) {
cameraCaptures++;
}
return rawStream;
};
const screenTrack = createFakeCameraTrack("screen-track");
const screenStream = {
...createFakeCameraStream("screen-stream", screenTrack),
getAudioTracks: () => [],
};
const originalGetDisplayMedia = navigator.mediaDevices.getDisplayMedia;
navigator.mediaDevices.getDisplayMedia = async () => screenStream;
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await this.subject.toggleScreenShare();
await this.subject.toggleScreenShare();
await wait(20);
assert.strictEqual(
cameraCaptures,
0,
"does not acquire a camera without a remembered camera-on choice"
);
assert.strictEqual(this.subject.localVideoKind, null, "leaves video off");
} finally {
if (originalGetDisplayMedia) {
navigator.mediaDevices.getDisplayMedia = originalGetDisplayMedia;
} else {
delete navigator.mediaDevices.getDisplayMedia;
}
audioEnvironment.restore();
}
});
test("the browser's stop-sharing action restores the previously enabled camera", async function (assert) {
setupCameraRoom(this);
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
let cameraCaptures = 0;
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.video) {
return rawStream;
}
cameraCaptures++;
const track = createFakeCameraTrack(`camera-track-${cameraCaptures}`);
return createFakeCameraStream(`camera-stream-${cameraCaptures}`, track);
};
const screenTrack = createEndableCameraTrack("screen-track");
const screenStream = {
...createFakeCameraStream("screen-stream", screenTrack),
getAudioTracks: () => [],
};
const originalGetDisplayMedia = navigator.mediaDevices.getDisplayMedia;
navigator.mediaDevices.getDisplayMedia = async () => screenStream;
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await this.subject.toggleCamera();
await this.subject.toggleScreenShare();
screenTrack.end();
await waitUntil(
() => this.subject.localVideoKind === "camera" && cameraCaptures === 2
);
assert.strictEqual(
this.subject.localVideoKind,
"camera",
"restores the camera after the browser ends screen capture"
);
} finally {
if (originalGetDisplayMedia) {
navigator.mediaDevices.getDisplayMedia = originalGetDisplayMedia;
} else {
delete navigator.mediaDevices.getDisplayMedia;
}
audioEnvironment.restore();
}
});
test("a delayed ended event cannot stop a replacement screen share", async function (assert) {
setupCameraRoom(this);
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
const cameraTrack = createEndableCameraTrack("camera-track");
const cameraStream = createFakeCameraStream("camera-stream", cameraTrack);
navigator.mediaDevices.getUserMedia = async (constraints) =>
constraints?.video ? cameraStream : rawStream;
const screenTrack = createFakeCameraTrack("screen-track");
const screenStream = {
...createFakeCameraStream("screen-stream", screenTrack),
getAudioTracks: () => [],
};
const originalGetDisplayMedia = navigator.mediaDevices.getDisplayMedia;
navigator.mediaDevices.getDisplayMedia = async () => screenStream;
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await this.subject.toggleCamera();
await this.subject.toggleScreenShare();
cameraTrack.end();
await wait(20);
assert.strictEqual(
this.subject.localVideoKind,
"screen",
"ignores an ended event from the replaced camera pipeline"
);
assert.false(
screenTrack.stopped,
"keeps the replacement screen track active"
);
} finally {
if (originalGetDisplayMedia) {
navigator.mediaDevices.getDisplayMedia = originalGetDisplayMedia;
} else {
delete navigator.mediaDevices.getDisplayMedia;
}
audioEnvironment.restore();
}
});
test("a failed explicit camera start does not create a preference", async function (assert) {
setupCameraRoom(this);
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (constraints?.video) {
throw new DOMException("Permission denied", "NotAllowedError");
}
return rawStream;
};
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await this.subject.toggleCamera();
assert.strictEqual(
this.keyValueStore.get(this.cameraPreferenceKey),
undefined,
"keeps future calls camera-off after capture is denied"
);
assert.strictEqual(
this.subject.connectionStateFor(1),
"connected",
"keeps the audio call connected"
);
} finally {
audioEnvironment.restore();
}
});
test("a failed automatic camera restore stays silent", async function (assert) {
setupCameraRoom(this);
this.keyValueStore.set({
key: this.cameraPreferenceKey,
value: "true",
});
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (constraints?.video) {
throw new DOMException("Camera unavailable", "NotFoundError");
}
return rawStream;
};
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await wait(20);
const toasts = this.owner.lookup("service:toasts");
assert.deepEqual(
toasts.errors,
[],
"does not report a background capture failure as a user action"
);
} finally {
audioEnvironment.restore();
}
});
test("a watched room restores the camera when its join finishes", async function (assert) {
setupCameraRoom(this);
this.keyValueStore.set({
key: this.cameraPreferenceKey,
value: "true",
});
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
let cameraCaptures = 0;
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.video) {
return rawStream;
}
cameraCaptures++;
const track = createFakeCameraTrack("camera-track");
return createFakeCameraStream("camera-stream", track);
};
try {
this.subject.setWatching(1, true);
await this.subject.join(this.room);
await waitUntil(() => this.subject.localVideoKind === "camera");
assert.strictEqual(
cameraCaptures,
1,
"restores after joining a room whose page was already visible"
);
} finally {
audioEnvironment.restore();
}
});
test("a camera click cancels an in-flight automatic restore", async function (assert) {
setupCameraRoom(this);
this.keyValueStore.set({
key: this.cameraPreferenceKey,
value: "true",
});
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
const cameraGranted = deferred();
const cameraTrack = createFakeCameraTrack("camera-track");
const cameraStream = createFakeCameraStream("camera-stream", cameraTrack);
let cameraCaptures = 0;
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.video) {
return rawStream;
}
cameraCaptures++;
return cameraGranted.promise;
};
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await waitUntil(() => cameraCaptures === 1);
const toggle = this.subject.toggleCamera();
cameraGranted.resolve(cameraStream);
await toggle;
assert.strictEqual(
this.subject.localVideoKind,
null,
"keeps the camera off after the explicit click"
);
assert.true(
cameraTrack.stopped,
"releases the capture granted after cancellation"
);
assert.strictEqual(
this.keyValueStore.get(this.cameraPreferenceKey),
undefined,
"remembers the explicit camera-off choice"
);
} finally {
audioEnvironment.restore();
}
});
test("a remembered camera does not bypass room video permissions", async function (assert) {
setupCameraRoom(this);
this.room.video_enabled = false;
this.keyValueStore.set({
key: this.cameraPreferenceKey,
value: "true",
});
const rawTrack = createFakeTrack("raw-track");
const rawStream = createFakeStream("raw-stream", rawTrack);
const audioEnvironment = installFakeAudioEnvironment({
rawStream,
processedStream: rawStream,
});
let cameraCaptures = 0;
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (constraints?.video) {
cameraCaptures++;
}
return rawStream;
};
try {
await this.subject.join(this.room);
this.subject.setWatching(1, true);
await wait(20);
assert.strictEqual(
cameraCaptures,
0,
"does not acquire video in a room where publishing is forbidden"
);
assert.strictEqual(
this.keyValueStore.get(this.cameraPreferenceKey),
"true",
"preserves the preference for a later eligible room"
);
} finally {
audioEnvironment.restore();
}
});
test("setVideoInputDevice releases the live camera and retries when the hardware is busy", async function (assert) {
setupCameraRoom(this);