95 lines
4.4 KiB
TypeScript
95 lines
4.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||
import { resolveDisplayLayout } from "../platform/displayLayout";
|
||
import { subscribeControllerToken } from "../input/controller";
|
||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||
|
||
export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const dedicatedSurface = params.get("display");
|
||
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
|
||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
|
||
const activeSurfaceRef = useRef(activeSurface);
|
||
activeSurfaceRef.current = activeSurface;
|
||
const showSurface = useCallback((surface: DisplaySurface) => {
|
||
activeSurfaceRef.current = surface;
|
||
setActiveSurface(surface);
|
||
}, []);
|
||
const toggleSurface = useCallback(() => {
|
||
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
|
||
if (layout === "thor-preview") return;
|
||
requestDisplaySurface("top");
|
||
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
|
||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||
if (token === "Button8" && !repeat) toggleSurface();
|
||
});
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.repeat) return;
|
||
if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
|
||
event.preventDefault();
|
||
requestDisplaySurface("top");
|
||
return;
|
||
}
|
||
if (event.key !== "Tab") return;
|
||
event.preventDefault();
|
||
toggleSurface();
|
||
};
|
||
window.addEventListener("keydown", onKeyDown);
|
||
return () => {
|
||
window.removeEventListener("keydown", onKeyDown);
|
||
unsubscribeSurface();
|
||
unsubscribeController();
|
||
requestDisplaySurface("top");
|
||
};
|
||
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
|
||
|
||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
|
||
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
|
||
}
|
||
|
||
if (layout === "single") {
|
||
const contextOpen = activeSurface === "bottom";
|
||
return (
|
||
<div className={`single-display-frame ${contextOpen ? "context-open" : ""}`}>
|
||
<div className="single-primary-surface">{top}</div>
|
||
{contextOpen && (
|
||
<div className="single-context-layer" role="dialog" aria-modal="true" aria-label={`${contextLabel} interface`}>
|
||
<button className="single-context-backdrop" onClick={() => requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
|
||
<div className="single-context-surface">{bottom}</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
className="single-context-toggle"
|
||
onClick={toggleSurface}
|
||
aria-expanded={contextOpen}
|
||
aria-label={contextOpen ? "Return to main view" : `Open ${contextLabel.toLowerCase()} interface`}
|
||
>
|
||
<b>{contextOpen ? "Return" : contextLabel}</b>
|
||
<small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`device-frame active-${activeSurface}`}>
|
||
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
|
||
<div className={`surface-slot top-slot ${activeSurface === "top" ? "is-active" : ""}`}>{top}</div>
|
||
<div className="hinge" aria-hidden="true"><i /><b>AYN THOR</b><i /></div>
|
||
<div className="screen-label bottom-label"><span>Context display</span><small>620 × 540 CSS · 1240 × 1080 · 60Hz</small></div>
|
||
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
|
||
<button
|
||
className="native-display-switch"
|
||
onClick={toggleSurface}
|
||
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
||
>
|
||
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|