/** * FullscreenDisplay * * Takes over the screen of an iOS device running Safari. * - Requests fullscreen / uses iOS standalone-mode tricks * - Creates a canvas sized to the true physical pixel resolution * (CSS pixels × devicePixelRatio) * - Default state is solid black * - Acquires a Screen Wake Lock so the display never sleeps while active; * automatically re-acquires the lock if the page is briefly hidden then * shown again (iOS releases wake locks on page hide) * - display(image, ms) renders an HTMLImageElement for exactly `ms` * milliseconds, then reverts to black, using requestAnimationFrame * timestamps for sub-frame accuracy */ export class FullscreenDisplay { // ─── Public readonly state ─────────────────────────────────────────────── /** The backing element. */ public readonly canvas: HTMLCanvasElement; /** Physical pixel width (CSS width × devicePixelRatio). */ public readonly physicalWidth: number; /** Physical pixel height (CSS height × devicePixelRatio). */ public readonly physicalHeight: number; /** Device pixel ratio detected at construction time. */ public readonly devicePixelRatio: number; // ─── Private state ─────────────────────────────────────────────────────── private readonly ctx: CanvasRenderingContext2D; /** Overlay
that acts as the fullscreen root. */ private readonly overlay: HTMLDivElement; /** rAF handle so we can cancel an in-flight display call. */ private rafHandle: number | null = null; /** Resolves the Promise returned by the active display() call. */ private displayResolve: (() => void) | null = null; /** * Active Screen Wake Lock sentinel. * Typed loosely because lib.dom.d.ts only gained WakeLockSentinel * in TypeScript 4.4+; the cast below keeps older configs happy. */ private wakeLock: WakeLockSentinel | null = null; // ─── Construction ───────────────────────────────────────────────────────── constructor() { // ── 1. Determine true screen density ────────────────────────────────── // // window.devicePixelRatio is the authoritative source on iOS Safari. // Common values: 2× (most iPhones / standard iPads), 3× (Pro iPhones). this.devicePixelRatio = window.devicePixelRatio ?? 1; // ── 2. Build fullscreen overlay ──────────────────────────────────────── this.overlay = document.createElement("div"); const overlayStyle = this.overlay.style; overlayStyle.position = "fixed"; overlayStyle.inset = "0"; // top/right/bottom/left: 0 overlayStyle.width = "100%"; overlayStyle.height = "100%"; overlayStyle.margin = "0"; overlayStyle.padding = "0"; overlayStyle.backgroundColor = "#000"; overlayStyle.zIndex = "2147483647"; // max z-index // Prevent any rubber-band / scroll bleed-through on iOS overlayStyle.overflow = "hidden"; overlayStyle.touchAction = "none"; // GPU-composited layer – avoids repaint on canvas updates overlayStyle.transform = "translateZ(0)"; overlayStyle.webkitTransform = "translateZ(0)"; document.body.appendChild(this.overlay); // ── 3. Size the canvas to physical pixels ────────────────────────────── // // On iOS, window.innerWidth/Height gives the CSS pixel viewport after // the browser chrome has been accounted for. Multiply by DPR to get // the true raster resolution. const cssWidth = window.innerWidth; const cssHeight = window.innerHeight; this.physicalWidth = Math.round(cssWidth * this.devicePixelRatio); this.physicalHeight = Math.round(cssHeight * this.devicePixelRatio); this.canvas = document.createElement("canvas"); this.canvas.width = this.physicalWidth; this.canvas.height = this.physicalHeight; const canvasStyle = this.canvas.style; canvasStyle.display = "block"; canvasStyle.width = `${cssWidth}px`; // CSS size = viewport canvasStyle.height = `${cssHeight}px`; canvasStyle.backgroundColor = "#000"; this.overlay.appendChild(this.canvas); // ── 4. Grab 2-D context with best quality hints ──────────────────────── const ctx = this.canvas.getContext("2d", { alpha: false, // opaque → faster compositing desynchronized: false, // keep in sync with display }); if (ctx === null) { throw new Error("FullscreenDisplay: could not acquire 2D canvas context."); } this.ctx = ctx; // Scale once so every draw call works in physical pixels automatically. // (We do NOT use CSS transform scaling so image quality is always 1:1.) this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); // ── 5. Paint initial black frame ─────────────────────────────────────── this.paintBlack(); // ── 6. Attempt fullscreen (Web Fullscreen API) ───────────────────────── // // iOS 16.4+ supports Element.requestFullscreen() on Safari. // Older versions ignore it silently; the fixed overlay still covers // the screen, so we degrade gracefully. this.requestNativeFullscreen(); // ── 7. Listen for resize (orientation change, split-view, etc.) ──────── window.addEventListener("resize", this.handleResize); // ── 8. Acquire Screen Wake Lock ──────────────────────────────────────── // // Prevents iOS from dimming or auto-locking the screen. // Supported in Safari 16.4+; silently no-ops on older versions. // The lock is automatically released by the browser whenever the page // is hidden (e.g. user switches apps), so we re-acquire it on // visibilitychange. this.acquireWakeLock(); document.addEventListener("visibilitychange", this.handleVisibilityChange); } // ─── Public API ─────────────────────────────────────────────────────────── /** * Display `image` for exactly `durationMs` milliseconds, then go back to * black. Returns a Promise that resolves when the black frame is shown. * * If called while a previous display() is still running, the previous one * is cancelled immediately (image removed, black shown) before the new * one starts. * * @param image A fully-loaded HTMLImageElement. * @param durationMs How long (in milliseconds) to show the image. */ public display(image: HTMLImageElement, durationMs: number): Promise { // Cancel any in-flight display. this.cancelCurrentDisplay(); return new Promise((resolve) => { this.displayResolve = resolve; // We capture the rAF timestamp on the very first paint frame to use // as the precise start time, avoiding any lag between Promise // construction and actual screen output. let startTime: number | null = null; const frame = (timestamp: DOMHighResTimeStamp): void => { // ── First frame: record start, draw image ──────────────────────── if (startTime === null) { startTime = timestamp; this.paintImage(image); } const elapsed = timestamp - startTime; if (elapsed < durationMs) { // Still within the window – schedule next check. this.rafHandle = requestAnimationFrame(frame); } else { // Duration expired – go back to black and resolve. this.paintBlack(); this.rafHandle = null; this.displayResolve = null; resolve(); } }; this.rafHandle = requestAnimationFrame(frame); }); } /** * Manually cancel any active display() and return to black immediately. */ public cancelCurrentDisplay(): void { if (this.rafHandle !== null) { cancelAnimationFrame(this.rafHandle); this.rafHandle = null; } if (this.displayResolve !== null) { this.paintBlack(); const resolve = this.displayResolve; this.displayResolve = null; resolve(); } } /** * Tear down the overlay and release all resources. * Cancels any running display() first. */ public destroy(): void { this.cancelCurrentDisplay(); window.removeEventListener("resize", this.handleResize); document.removeEventListener("visibilitychange", this.handleVisibilityChange); this.releaseWakeLock(); if (document.fullscreenElement === this.overlay) { document.exitFullscreen().catch(() => {/* ignore */}); } this.overlay.remove(); } // ─── Private helpers ────────────────────────────────────────────────────── /** Fill the entire canvas with solid black. */ private paintBlack(): void { // Reset transform before raw pixel fill to avoid double-scaling. this.ctx.save(); this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.fillStyle = "#000000"; this.ctx.fillRect(0, 0, this.physicalWidth, this.physicalHeight); this.ctx.restore(); } /** * Draw `image` scaled to fill the canvas while preserving aspect ratio * (object-fit: contain semantics, centred, letterboxed with black). */ private paintImage(image: HTMLImageElement): void { const cssWidth = window.innerWidth; const cssHeight = window.innerHeight; const imgW = image.naturalWidth || image.width; const imgH = image.naturalHeight || image.height; if (imgW === 0 || imgH === 0) { // Malformed image – just show black. this.paintBlack(); return; } const scale = Math.min(cssWidth / imgW, cssHeight / imgH); const drawW = imgW * scale; const drawH = imgH * scale; const drawX = (cssWidth - drawW) / 2; const drawY = (cssHeight - drawH) / 2; // Background black (letterbox / pillarbox areas). this.paintBlack(); // The canvas context was scaled by DPR at construction, so all // drawImage coordinates here are in CSS pixels – which is exactly // what we want. this.ctx.drawImage(image, drawX, drawY, drawW, drawH); } /** Attempt the Web Fullscreen API; silently no-op if unavailable. */ private requestNativeFullscreen(): void { const el = this.overlay as HTMLDivElement & { webkitRequestFullscreen?: () => Promise; }; if (typeof el.requestFullscreen === "function") { el.requestFullscreen({ navigationUI: "hide" }).catch(() => {/* ok */}); } else if (typeof el.webkitRequestFullscreen === "function") { // Older Safari / WebKit prefix el.webkitRequestFullscreen(); } } // ─── Wake Lock helpers ──────────────────────────────────────────────────── /** * Request a 'screen' wake lock via the Screen Wake Lock API. * Fails silently if the API is unavailable or the request is rejected * (e.g. Low Power Mode on iOS). */ private acquireWakeLock(): void { if (!("wakeLock" in navigator)) return; (navigator.wakeLock as WakeLock) .request("screen") .then((sentinel: WakeLockSentinel) => { this.wakeLock = sentinel; // If the sentinel is released externally (browser-initiated), // clear our reference so we know to re-acquire on next // visibilitychange. sentinel.addEventListener("release", () => { if (this.wakeLock === sentinel) { this.wakeLock = null; } }); }) .catch(() => { // Permission denied, Low Power Mode, or API unavailable – no-op. }); } /** Release the active wake lock, if any. */ private releaseWakeLock(): void { if (this.wakeLock !== null) { this.wakeLock.release().catch(() => {/* ignore */}); this.wakeLock = null; } } /** * Re-acquire the wake lock when the page returns to the foreground. * iOS releases all wake locks the moment the page is hidden, so this * is required to keep the screen alive after the user briefly switches * away and comes back. * * Arrow function so `this` is correctly bound as an event listener. */ private readonly handleVisibilityChange = (): void => { if (document.visibilityState === "visible") { this.acquireWakeLock(); } }; // ─── Resize helper ──────────────────────────────────────────────────────── /** * Resizes the canvas backing store and repaints black. * * Arrow function so `this` is always bound correctly as an event listener. */ private readonly handleResize = (): void => { const cssWidth = window.innerWidth; const cssHeight = window.innerHeight; const dpr = window.devicePixelRatio ?? 1; const newPhysW = Math.round(cssWidth * dpr); const newPhysH = Math.round(cssHeight * dpr); // Mutating readonly at runtime – use type assertion. (this as { physicalWidth: number }).physicalWidth = newPhysW; (this as { physicalHeight: number }).physicalHeight = newPhysH; this.canvas.width = newPhysW; this.canvas.height = newPhysH; this.canvas.style.width = `${cssWidth}px`; this.canvas.style.height = `${cssHeight}px`; // Re-apply the DPR scale after canvas resize (resizing resets transform). this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.scale(dpr, dpr); this.paintBlack(); }; }