191 lines
8.6 KiB
TypeScript
191 lines
8.6 KiB
TypeScript
/** Accessible webcam-face controls. This view never acquires or retains media. */
|
|
|
|
export type WebcamFacePanelStatus = "off" | "requesting" | "active" | "error" | "unsupported";
|
|
|
|
export interface WebcamFacePanelState {
|
|
status: WebcamFacePanelStatus;
|
|
message: string;
|
|
indicatorVisible: boolean;
|
|
}
|
|
|
|
export interface WebcamFacePanelOptions {
|
|
/** Contextual controls, normally mounted inside the character editor. */
|
|
container: HTMLElement;
|
|
/** Persistent chrome that remains visible after the character editor closes. */
|
|
indicatorContainer: HTMLElement;
|
|
supported?: boolean;
|
|
onStart: () => void | Promise<void>;
|
|
onStop: () => void;
|
|
}
|
|
|
|
export interface WebcamFacePanel {
|
|
root: HTMLElement;
|
|
indicator: HTMLElement;
|
|
/** Buttons to include in a containing modal's focus trap. */
|
|
focusables: readonly HTMLElement[];
|
|
state(): WebcamFacePanelState;
|
|
update(status: WebcamFacePanelStatus, message?: string): WebcamFacePanelState;
|
|
/** Restore modal keyboard focus after a browser permission prompt closes. */
|
|
focusStop(): void;
|
|
dispose(): void;
|
|
}
|
|
|
|
const STYLES = `
|
|
.tera-webcam-face { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hairline, rgba(255,255,255,.13)); }
|
|
.tera-webcam-face__heading { margin: 0 0 4px; color: white; font: 600 13px/1.3 inherit; }
|
|
.tera-webcam-face__copy { margin: 0 0 10px; color: var(--ink-2, rgba(255,255,255,.62)); }
|
|
.tera-webcam-face__row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
|
.tera-webcam-face__button {
|
|
min-height: 40px; padding: 8px 14px; color: var(--ink, white); cursor: pointer;
|
|
background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15));
|
|
border-radius: var(--r-sm, 5px); font: inherit;
|
|
}
|
|
.tera-webcam-face__button:hover:not(:disabled) { background: rgba(255,255,255,.12); }
|
|
.tera-webcam-face__button:focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; }
|
|
.tera-webcam-face__button:disabled { cursor: wait; opacity: .55; }
|
|
.tera-webcam-face__status { margin: 0; color: var(--ink-2, rgba(255,255,255,.62)); }
|
|
.tera-webcam-face[data-status="active"] .tera-webcam-face__status { color: #8fd7aa; }
|
|
.tera-webcam-face[data-status="error"] .tera-webcam-face__status,
|
|
.tera-webcam-face[data-status="unsupported"] .tera-webcam-face__status { color: #ffb2aa; }
|
|
.tera-webcam-indicator {
|
|
position: fixed; top: calc(var(--s4, 16px) + env(safe-area-inset-top) + 4.55rem); right: var(--s4, 16px);
|
|
z-index: 12; display: flex; align-items: center; gap: 9px; padding: 8px 10px;
|
|
color: #dff8e8; background: rgba(8, 32, 20, .92); border: 1px solid rgba(100, 220, 145, .45);
|
|
border-radius: var(--r-sm, 5px); box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.35));
|
|
font: 11px/1.4 ui-monospace, "SF Mono", Menlo, monospace;
|
|
}
|
|
.tera-webcam-indicator__dot { width: 7px; height: 7px; border-radius: 50%; background: #65dc91; box-shadow: 0 0 0 3px rgba(101,220,145,.14); }
|
|
.tera-webcam-indicator__stop { min-height: 30px; padding: 4px 8px; color: inherit; background: transparent; border: 1px solid rgba(143,215,170,.4); border-radius: 4px; font: inherit; cursor: pointer; }
|
|
.tera-webcam-indicator__stop:focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; }
|
|
@media (max-width: 600px) { .tera-webcam-indicator { top: calc(var(--s3, 12px) + env(safe-area-inset-top) + 5rem); right: var(--s3, 12px); } }
|
|
`;
|
|
|
|
const DEFAULT_MESSAGES: Record<WebcamFacePanelStatus, string> = {
|
|
off: "Camera is off. Nothing is captured or stored.",
|
|
requesting: "Waiting for camera permission…",
|
|
active: "Live on your humanoid face. Never recorded or uploaded.",
|
|
error: "Camera could not start. Check browser permission and try again.",
|
|
unsupported: "Camera faces are not supported by this browser.",
|
|
};
|
|
|
|
export function createWebcamFacePanel(options: WebcamFacePanelOptions): WebcamFacePanel {
|
|
if (!options.container || typeof options.container.append !== "function" ||
|
|
!options.indicatorContainer || typeof options.indicatorContainer.append !== "function") {
|
|
throw new RangeError("webcam face panel: valid containers are required");
|
|
}
|
|
if (typeof options.onStart !== "function" || typeof options.onStop !== "function") {
|
|
throw new RangeError("webcam face panel: start and stop handlers are required");
|
|
}
|
|
const doc = options.container.ownerDocument;
|
|
const root = doc.createElement("section");
|
|
root.className = "tera-webcam-face";
|
|
const style = doc.createElement("style");
|
|
style.setAttribute("data-tera-webcam-face-style", "");
|
|
style.textContent = STYLES;
|
|
root.append(style);
|
|
|
|
const heading = doc.createElement("h3");
|
|
heading.className = "tera-webcam-face__heading";
|
|
heading.textContent = "Live camera face";
|
|
const copy = doc.createElement("p");
|
|
copy.className = "tera-webcam-face__copy";
|
|
copy.textContent = "Optionally put your camera on this humanoid for this tab only. It is never saved, recorded, or uploaded.";
|
|
const row = doc.createElement("div");
|
|
row.className = "tera-webcam-face__row";
|
|
const start = doc.createElement("button");
|
|
start.type = "button";
|
|
start.className = "tera-webcam-face__button";
|
|
start.setAttribute("data-action", "start-camera");
|
|
start.textContent = "Start camera";
|
|
const stop = doc.createElement("button");
|
|
stop.type = "button";
|
|
stop.className = "tera-webcam-face__button";
|
|
stop.setAttribute("data-action", "stop-camera");
|
|
stop.textContent = "Stop camera";
|
|
const status = doc.createElement("p");
|
|
status.className = "tera-webcam-face__status";
|
|
status.setAttribute("role", "status");
|
|
status.setAttribute("aria-live", "polite");
|
|
row.append(start, stop, status);
|
|
root.append(heading, copy, row);
|
|
|
|
const indicator = doc.createElement("div");
|
|
indicator.className = "tera-webcam-indicator";
|
|
indicator.setAttribute("role", "region");
|
|
indicator.setAttribute("aria-label", "Camera active");
|
|
const dot = doc.createElement("span");
|
|
dot.className = "tera-webcam-indicator__dot";
|
|
dot.setAttribute("aria-hidden", "true");
|
|
const indicatorText = doc.createElement("span");
|
|
indicatorText.setAttribute("role", "status");
|
|
indicatorText.setAttribute("aria-live", "polite");
|
|
indicatorText.textContent = "Camera active · local face only";
|
|
const indicatorStop = doc.createElement("button");
|
|
indicatorStop.type = "button";
|
|
indicatorStop.className = "tera-webcam-indicator__stop";
|
|
indicatorStop.setAttribute("data-action", "stop-camera-indicator");
|
|
indicatorStop.textContent = "Stop";
|
|
indicator.append(dot, indicatorText, indicatorStop);
|
|
|
|
options.container.append(root);
|
|
options.indicatorContainer.append(indicator);
|
|
let disposed = false;
|
|
let current: WebcamFacePanelState = {
|
|
status: options.supported === false ? "unsupported" : "off",
|
|
message: DEFAULT_MESSAGES[options.supported === false ? "unsupported" : "off"],
|
|
indicatorVisible: false,
|
|
};
|
|
|
|
function snapshot(): WebcamFacePanelState { return { ...current }; }
|
|
function render(): void {
|
|
root.setAttribute("data-status", current.status);
|
|
status.textContent = current.message;
|
|
const active = current.status === "active";
|
|
const requesting = current.status === "requesting";
|
|
start.hidden = active;
|
|
start.disabled = requesting || current.status === "unsupported";
|
|
stop.hidden = !active && !requesting;
|
|
indicator.hidden = !current.indicatorVisible;
|
|
// Starting capture completes asynchronously. Do not leave keyboard focus on
|
|
// the Start button at the moment it becomes hidden; move it to the visible
|
|
// in-dialog Stop control so Escape and the modal focus trap keep working.
|
|
if (active) stop.focus();
|
|
}
|
|
function stopIntent(): void {
|
|
if (!disposed && (current.status === "active" || current.status === "requesting")) options.onStop();
|
|
}
|
|
start.addEventListener("click", () => {
|
|
if (!disposed && current.status !== "requesting" && current.status !== "active" && current.status !== "unsupported") {
|
|
void options.onStart();
|
|
}
|
|
});
|
|
stop.addEventListener("click", stopIntent);
|
|
indicatorStop.addEventListener("click", stopIntent);
|
|
render();
|
|
|
|
return {
|
|
root,
|
|
indicator,
|
|
focusables: [start, stop],
|
|
state: snapshot,
|
|
update(next, message = DEFAULT_MESSAGES[next]) {
|
|
if (disposed) return snapshot();
|
|
if (!(next in DEFAULT_MESSAGES) || typeof message !== "string") {
|
|
throw new RangeError("webcam face panel: invalid state");
|
|
}
|
|
current = { status: next, message, indicatorVisible: next === "active" };
|
|
render();
|
|
return snapshot();
|
|
},
|
|
focusStop() {
|
|
if (!disposed && current.status === "active") stop.focus();
|
|
},
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
root.remove();
|
|
indicator.remove();
|
|
},
|
|
};
|
|
}
|