filmout_manager/browser/display.ts

370 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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