filmout_manager/browser/display.ts

370 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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();
};
}