feat: add playable flight and office screen sharing
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
/** Accessible, capture-free command panel for authored office screens. */
|
||||
|
||||
import type { MediaSurfaceDescriptor } from "./presentation.ts";
|
||||
|
||||
export interface OfficeScreenPanelOptions {
|
||||
container: HTMLElement;
|
||||
surfaces: readonly MediaSurfaceDescriptor[];
|
||||
onSelect?: (surface: MediaSurfaceDescriptor) => void;
|
||||
/** Intent only. The caller may prompt for capture after receiving it. */
|
||||
onRequestShare?: (surface: MediaSurfaceDescriptor) => void;
|
||||
/** Intent only. The caller owns presenter stop/revoke policy and media. */
|
||||
onStopShare?: (surface: MediaSurfaceDescriptor) => void;
|
||||
onViewerOptIn?: (surface: MediaSurfaceDescriptor, optedIn: boolean) => void;
|
||||
}
|
||||
|
||||
export interface OfficeScreenPanelState {
|
||||
open: boolean;
|
||||
selectedId: string | null;
|
||||
/** Explicit, local viewing choices. Empty initially and after disposal. */
|
||||
optedInScreenIds: string[];
|
||||
surfaces: MediaSurfaceDescriptor[];
|
||||
}
|
||||
|
||||
export interface OfficeScreenPanel {
|
||||
root: HTMLElement;
|
||||
open(): OfficeScreenPanelState;
|
||||
update(surfaces: readonly MediaSurfaceDescriptor[]): OfficeScreenPanelState;
|
||||
close(): OfficeScreenPanelState;
|
||||
state(): OfficeScreenPanelState;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const STYLES = `
|
||||
.tera-screen-panel { width:min(520px,calc(100vw - 32px)); max-height:min(720px,calc(100dvh - 32px)); overflow:auto; padding:20px; color:#e8edf2; background:rgba(9,13,18,.94); border:1px solid rgba(255,255,255,.14); border-radius:8px; font:12px/1.5 ui-monospace,monospace; }
|
||||
.tera-screen-panel__title { margin:0 0 4px; font-size:17px; }
|
||||
.tera-screen-panel__intro,.tera-screen-panel__empty { color:rgba(255,255,255,.65); }
|
||||
.tera-screen-panel__list { display:grid; gap:8px; margin:16px 0; }
|
||||
.tera-screen-panel__screen { width:100%; padding:10px; color:inherit; text-align:left; background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.13); border-radius:5px; cursor:pointer; }
|
||||
.tera-screen-panel__screen[aria-pressed="true"] { border-color:#f2b134; background:rgba(242,177,52,.1); }
|
||||
.tera-screen-panel__meta { display:block; color:rgba(255,255,255,.58); }
|
||||
.tera-screen-panel__actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
|
||||
.tera-screen-panel__button { min-height:40px; padding:8px 12px; color:inherit; background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.15); border-radius:5px; cursor:pointer; }
|
||||
.tera-screen-panel__button:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.tera-screen-panel :focus-visible { outline:2px solid #f2b134; outline-offset:2px; }
|
||||
`;
|
||||
|
||||
let panelSequence = 0;
|
||||
|
||||
export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): OfficeScreenPanel {
|
||||
if (!options.container || typeof options.container.append !== "function") {
|
||||
throw new RangeError("office screen panel: container must be an HTMLElement");
|
||||
}
|
||||
let surfaces = validateSurfaces(options.surfaces);
|
||||
let selectedId: string | null = surfaces[0]?.screenId ?? null;
|
||||
const optedIn = new Set<string>();
|
||||
let isOpen = false;
|
||||
let disposed = false;
|
||||
let invoker: HTMLElement | null = null;
|
||||
|
||||
const doc = options.container.ownerDocument;
|
||||
const number = ++panelSequence;
|
||||
const titleId = `tera-screen-panel-title-${number}`;
|
||||
const introId = `tera-screen-panel-intro-${number}`;
|
||||
const root = doc.createElement("section");
|
||||
root.className = "tera-screen-panel";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.setAttribute("aria-labelledby", titleId);
|
||||
root.setAttribute("aria-describedby", introId);
|
||||
root.setAttribute("aria-hidden", "true");
|
||||
root.hidden = true;
|
||||
|
||||
const style = doc.createElement("style");
|
||||
style.textContent = STYLES;
|
||||
const title = doc.createElement("h2");
|
||||
title.id = titleId;
|
||||
title.className = "tera-screen-panel__title";
|
||||
title.textContent = "Office screens";
|
||||
const intro = doc.createElement("p");
|
||||
intro.id = introId;
|
||||
intro.className = "tera-screen-panel__intro";
|
||||
intro.textContent = "Media is off by default. Choose a screen, then explicitly opt in to view or request sharing.";
|
||||
const list = doc.createElement("div");
|
||||
list.className = "tera-screen-panel__list";
|
||||
list.setAttribute("role", "list");
|
||||
const actions = doc.createElement("div");
|
||||
actions.className = "tera-screen-panel__actions";
|
||||
const optButton = button(doc, "Opt in to view", "opt-in");
|
||||
const shareButton = button(doc, "Share screen", "share");
|
||||
const stopButton = button(doc, "Stop / revoke", "stop");
|
||||
const closeButton = button(doc, "Close", "close");
|
||||
actions.append(optButton, shareButton, stopButton, closeButton);
|
||||
root.append(style, title, intro, list, actions);
|
||||
options.container.append(root);
|
||||
|
||||
function selected(): MediaSurfaceDescriptor | null {
|
||||
return surfaces.find((surface) => surface.screenId === selectedId) ?? null;
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
for (const child of [...list.children]) child.remove();
|
||||
if (surfaces.length === 0) {
|
||||
const empty = doc.createElement("p");
|
||||
empty.className = "tera-screen-panel__empty";
|
||||
empty.textContent = "No authored office screens are available.";
|
||||
list.append(empty);
|
||||
}
|
||||
for (const surface of surfaces) {
|
||||
const row = button(doc, "", "select");
|
||||
row.className = "tera-screen-panel__screen";
|
||||
row.setAttribute("role", "listitem");
|
||||
row.setAttribute("data-screen-id", surface.screenId);
|
||||
row.setAttribute("aria-pressed", String(surface.screenId === selectedId));
|
||||
const name = doc.createElement("span");
|
||||
name.textContent = surface.screenId;
|
||||
const meta = doc.createElement("span");
|
||||
meta.className = "tera-screen-panel__meta";
|
||||
const room = surface.roomId ?? "Unassigned room";
|
||||
const status = surface.bound ? "Media active" : "Media off";
|
||||
meta.textContent = `${surface.levelId} · ${room} · ${status}`;
|
||||
row.append(name, meta);
|
||||
row.addEventListener("click", () => {
|
||||
selectedId = surface.screenId;
|
||||
render();
|
||||
options.onSelect?.(copySurface(surface));
|
||||
});
|
||||
list.append(row);
|
||||
}
|
||||
const surface = selected();
|
||||
const viewing = surface ? optedIn.has(surface.screenId) : false;
|
||||
optButton.disabled = surface === null;
|
||||
shareButton.disabled = surface === null || !viewing;
|
||||
stopButton.disabled = surface === null || !surface.bound;
|
||||
optButton.textContent = viewing ? "Stop viewing" : "Opt in to view";
|
||||
optButton.setAttribute("aria-pressed", String(viewing));
|
||||
}
|
||||
|
||||
optButton.addEventListener("click", () => {
|
||||
const surface = selected();
|
||||
if (!surface) return;
|
||||
const next = !optedIn.has(surface.screenId);
|
||||
if (next) optedIn.add(surface.screenId);
|
||||
else optedIn.delete(surface.screenId);
|
||||
render();
|
||||
options.onViewerOptIn?.(copySurface(surface), next);
|
||||
});
|
||||
shareButton.addEventListener("click", () => {
|
||||
const surface = selected();
|
||||
if (surface) options.onRequestShare?.(copySurface(surface));
|
||||
});
|
||||
stopButton.addEventListener("click", () => {
|
||||
const surface = selected();
|
||||
if (surface?.bound) options.onStopShare?.(copySurface(surface));
|
||||
});
|
||||
closeButton.addEventListener("click", () => close());
|
||||
|
||||
function snapshot(): OfficeScreenPanelState {
|
||||
return {
|
||||
open: isOpen,
|
||||
selectedId,
|
||||
optedInScreenIds: [...optedIn],
|
||||
surfaces: surfaces.map(copySurface),
|
||||
};
|
||||
}
|
||||
|
||||
function close(): OfficeScreenPanelState {
|
||||
if (disposed || !isOpen) return snapshot();
|
||||
isOpen = false;
|
||||
root.hidden = true;
|
||||
root.setAttribute("aria-hidden", "true");
|
||||
invoker?.focus();
|
||||
invoker = null;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
root.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls();
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (!event.shiftKey && doc.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
} else if (event.shiftKey && doc.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function controls(): HTMLElement[] {
|
||||
const rows = [...list.children].filter((child): child is HTMLElement =>
|
||||
(child as HTMLElement).getAttribute("data-screen-id") !== null,
|
||||
);
|
||||
return [...rows, optButton, shareButton, stopButton, closeButton].filter(
|
||||
(control) => !(control as HTMLButtonElement).disabled,
|
||||
);
|
||||
}
|
||||
|
||||
render();
|
||||
return {
|
||||
root,
|
||||
open() {
|
||||
if (disposed) return snapshot();
|
||||
const active = doc.activeElement as HTMLElement | null;
|
||||
invoker = active && typeof active.focus === "function" ? active : null;
|
||||
isOpen = true;
|
||||
root.hidden = false;
|
||||
root.setAttribute("aria-hidden", "false");
|
||||
controls()[0]?.focus();
|
||||
return snapshot();
|
||||
},
|
||||
update(next) {
|
||||
if (disposed) return snapshot();
|
||||
surfaces = validateSurfaces(next);
|
||||
const ids = new Set(surfaces.map((surface) => surface.screenId));
|
||||
for (const id of optedIn) if (!ids.has(id)) optedIn.delete(id);
|
||||
if (selectedId === null || !ids.has(selectedId)) selectedId = surfaces[0]?.screenId ?? null;
|
||||
render();
|
||||
return snapshot();
|
||||
},
|
||||
close,
|
||||
state: snapshot,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
close();
|
||||
disposed = true;
|
||||
optedIn.clear();
|
||||
surfaces = [];
|
||||
selectedId = null;
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateSurfaces(input: readonly MediaSurfaceDescriptor[]): MediaSurfaceDescriptor[] {
|
||||
if (!Array.isArray(input)) throw new TypeError("office screen panel: surfaces must be an array");
|
||||
const seen = new Set<string>();
|
||||
return input.map((surface) => {
|
||||
if (!surface || typeof surface !== "object") throw new TypeError("office screen panel: invalid surface");
|
||||
for (const field of ["screenId", "officeId", "levelId", "kind"] as const) {
|
||||
if (typeof surface[field] !== "string" || surface[field].length === 0) {
|
||||
throw new TypeError(`office screen panel: invalid ${field}`);
|
||||
}
|
||||
}
|
||||
if (surface.roomId !== null && typeof surface.roomId !== "string") {
|
||||
throw new TypeError("office screen panel: invalid roomId");
|
||||
}
|
||||
if (typeof surface.bound !== "boolean") throw new TypeError("office screen panel: invalid bound state");
|
||||
if (seen.has(surface.screenId)) throw new TypeError("office screen panel: duplicate screenId");
|
||||
seen.add(surface.screenId);
|
||||
return copySurface(surface);
|
||||
});
|
||||
}
|
||||
|
||||
function copySurface(surface: MediaSurfaceDescriptor): MediaSurfaceDescriptor {
|
||||
return { ...surface };
|
||||
}
|
||||
|
||||
function button(doc: Document, label: string, action: string): HTMLButtonElement {
|
||||
const value = doc.createElement("button");
|
||||
value.type = "button";
|
||||
value.className = "tera-screen-panel__button";
|
||||
value.textContent = label;
|
||||
value.setAttribute("data-action", action);
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user