mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user