370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
/**
|
||
* 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 <canvas> 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 <div> 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<void> {
|
||
// Cancel any in-flight display.
|
||
this.cancelCurrentDisplay();
|
||
|
||
return new Promise<void>((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<void>;
|
||
};
|
||
|
||
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();
|
||
};
|
||
}
|