feat: add ephemeral webcam faces and adaptive screens
This commit is contained in:
+126
-1
@@ -73,11 +73,18 @@ import {
|
||||
actorKindForPresence,
|
||||
createProfileEditor,
|
||||
createDefaultLocalProfile,
|
||||
createWebcamCapture,
|
||||
createWebcamFaceConsent,
|
||||
createWebcamFacePanel,
|
||||
createWebcamFaceTexture,
|
||||
loadLocalProfile,
|
||||
resolveHumanoidAppearance,
|
||||
saveLocalProfile,
|
||||
type LocalProfile,
|
||||
type ProfileEditor,
|
||||
type WebcamCaptureController,
|
||||
type WebcamFacePanel,
|
||||
type WebcamFaceTextureAdapter,
|
||||
} from "./profile/index.ts";
|
||||
import type { ActorIdentity } from "./actors/controller.ts";
|
||||
import { SRGBColorSpace, VideoTexture } from "three";
|
||||
@@ -964,6 +971,7 @@ async function mountCity(id: string) {
|
||||
return;
|
||||
}
|
||||
city = handle;
|
||||
attachCurrentWebcamFace();
|
||||
moveRealtimePresence();
|
||||
// A new board builds a new layer, and a new layer starts visible. Reapply
|
||||
// whatever the panel last said, or the setting silently undoes itself on the
|
||||
@@ -1282,6 +1290,7 @@ async function enterOffice() {
|
||||
}
|
||||
city.stage.setScene(office);
|
||||
inside = true;
|
||||
attachCurrentWebcamFace();
|
||||
moveRealtimePresence();
|
||||
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
|
||||
journeyToCity(desiredCity);
|
||||
@@ -1408,6 +1417,7 @@ function leaveOffice() {
|
||||
dispatchJourney({ type: "leave-office" });
|
||||
city.stage.setScene(city.stageScene);
|
||||
inside = false;
|
||||
attachCurrentWebcamFace();
|
||||
moveRealtimePresence();
|
||||
showPlan();
|
||||
showDetail(null);
|
||||
@@ -1587,8 +1597,13 @@ const screensButton = document.querySelector<HTMLButtonElement>("#screens");
|
||||
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
|
||||
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
|
||||
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
|
||||
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
|
||||
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
|
||||
let profileEditor: ProfileEditor | null = null;
|
||||
let webcamFacePanel: WebcamFacePanel | null = null;
|
||||
let webcamFaceTexture: WebcamFaceTextureAdapter | null = null;
|
||||
let webcamFaceConsent = createWebcamFaceConsent();
|
||||
let webcamCapture: WebcamCaptureController | null = null;
|
||||
|
||||
function showDetail(text: string | null) {
|
||||
const card = document.querySelector<HTMLElement>("#detail");
|
||||
@@ -2116,6 +2131,7 @@ function clearRealtimePeers(): void {
|
||||
|
||||
function applyRealtimeMessage(message: ServerRealtimeMessage): void {
|
||||
if (message.type === "membership-revoked") {
|
||||
stopWebcamFace(true);
|
||||
clearRealtimePeers();
|
||||
return;
|
||||
}
|
||||
@@ -2236,6 +2252,7 @@ async function initializeRealtimePresence(): Promise<void> {
|
||||
|
||||
window.addEventListener("pagehide", () => {
|
||||
realtimePageActive = false;
|
||||
stopWebcamFace(true);
|
||||
disposeOfficeScreenUi();
|
||||
realtimeOperation += 1;
|
||||
stopRealtimeSubscription?.();
|
||||
@@ -2267,6 +2284,96 @@ function applyProfilePreview(profile: LocalProfile): void {
|
||||
}
|
||||
}
|
||||
|
||||
function cameraSupported(): boolean {
|
||||
return typeof navigator.mediaDevices?.getUserMedia === "function";
|
||||
}
|
||||
|
||||
/** Attach the one ephemeral texture to whichever signed-in humanoid is current. */
|
||||
function attachCurrentWebcamFace(): void {
|
||||
const texture = webcamFaceTexture?.texture() ?? null;
|
||||
if (!texture || access.subject === null) return;
|
||||
city?.clearActorFaceTexture();
|
||||
office?.walker?.clearFaceTexture();
|
||||
if (inside) office?.walker?.attachFaceTexture(texture);
|
||||
else city?.attachActorFaceTexture(texture);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop app-owned capture. The texture adapter deliberately never owns tracks,
|
||||
* so this boundary must stop every one before dropping the stream reference.
|
||||
*/
|
||||
function stopWebcamFace(revoke = false): void {
|
||||
city?.clearActorFaceTexture();
|
||||
office?.walker?.clearFaceTexture();
|
||||
webcamFaceTexture?.clear();
|
||||
webcamCapture?.stop();
|
||||
if (revoke) webcamFaceConsent.revoke();
|
||||
else webcamFaceConsent.stop();
|
||||
webcamFaceTexture?.sync();
|
||||
webcamFacePanel?.update(cameraSupported() ? "off" : "unsupported");
|
||||
}
|
||||
|
||||
async function startWebcamFace(): Promise<void> {
|
||||
if (access.subject === null || !localProfile || !cameraSupported() || webcamCapture?.status() === "active" ||
|
||||
webcamCapture?.status() === "requesting") return;
|
||||
webcamFacePanel?.update("requesting");
|
||||
webcamFaceConsent.requestStart();
|
||||
webcamCapture ??= createWebcamCapture({
|
||||
acquire: () => navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 640 } },
|
||||
}),
|
||||
createVideo: () => {
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.autoplay = true;
|
||||
video.hidden = true;
|
||||
document.body.append(video);
|
||||
return video;
|
||||
},
|
||||
});
|
||||
let binding: Awaited<ReturnType<WebcamCaptureController["start"]>>;
|
||||
try {
|
||||
binding = await webcamCapture.start();
|
||||
} catch (error) {
|
||||
webcamFaceConsent.stop();
|
||||
const denied = error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "SecurityError");
|
||||
webcamFacePanel?.update(
|
||||
"error",
|
||||
denied ? "Camera permission was denied. Nothing was captured." : "Camera could not start. Check the device and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Null means Stop/pagehide overtook an asynchronous permission or play step.
|
||||
if (!binding) return;
|
||||
if (access.subject === null) {
|
||||
stopWebcamFace(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const consent = webcamFaceConsent.start(true);
|
||||
if (!consent.accepted) throw new Error("camera consent was no longer active");
|
||||
webcamFaceTexture ??= createWebcamFaceTexture({ consent: webcamFaceConsent });
|
||||
webcamFaceTexture.bind(binding);
|
||||
for (const track of binding.stream.getVideoTracks()) {
|
||||
track.addEventListener("ended", () => {
|
||||
if (webcamCapture?.binding()?.stream === binding.stream) stopWebcamFace();
|
||||
}, { once: true });
|
||||
}
|
||||
attachCurrentWebcamFace();
|
||||
webcamFacePanel?.update("active");
|
||||
// Native permission UI can restore focus to the page after the promise
|
||||
// resolves. Put it back inside the still-open modal on the next frame.
|
||||
requestAnimationFrame(() => {
|
||||
if (profileEditor?.state().open) webcamFacePanel?.focusStop();
|
||||
});
|
||||
} catch {
|
||||
stopWebcamFace();
|
||||
webcamFacePanel?.update("error", "Camera preview could not start. Nothing was retained.");
|
||||
}
|
||||
}
|
||||
|
||||
function ensureProfileEditor(): ProfileEditor | null {
|
||||
if (profileEditor) return profileEditor;
|
||||
if (!profileOverlay || !localProfile || access.subject === null) return null;
|
||||
@@ -2297,6 +2404,19 @@ function ensureProfileEditor(): ProfileEditor | null {
|
||||
},
|
||||
});
|
||||
profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation());
|
||||
if (webcamFaceIndicator) {
|
||||
const host = document.createElement("div");
|
||||
profileEditor.root.insertBefore(host, profileEditor.root.querySelector("form")?.nextSibling ?? null);
|
||||
webcamFacePanel = createWebcamFacePanel({
|
||||
container: host,
|
||||
indicatorContainer: webcamFaceIndicator,
|
||||
supported: cameraSupported(),
|
||||
onStart: startWebcamFace,
|
||||
onStop: () => stopWebcamFace(),
|
||||
});
|
||||
if (webcamCapture?.status() === "active") webcamFacePanel.update("active");
|
||||
profileEditor.registerFocusables(webcamFacePanel.focusables);
|
||||
}
|
||||
return profileEditor;
|
||||
}
|
||||
|
||||
@@ -2526,7 +2646,12 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
|
||||
try {
|
||||
showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { displaySurface: "browser" },
|
||||
video: {
|
||||
displaySurface: "browser",
|
||||
width: { ideal: 1_280, max: 1_280 },
|
||||
height: { ideal: 720, max: 720 },
|
||||
frameRate: { ideal: 15, max: 15 },
|
||||
},
|
||||
audio: false,
|
||||
// Chromium honours these as chooser preferences. Other browsers ignore
|
||||
// unknown dictionary members and still require the same explicit prompt.
|
||||
|
||||
Reference in New Issue
Block a user