// Screen Wake Lock manager — sanctioned JS island (engine, not a UI // renderer). Holds the display awake while the reader/player session is // active, gated by the user's keep_screen_awake profile preference // (default on). // // Spec boundaries this module works within: // - The browser force-releases every screen wake lock when the page // hides (tab switch, minimize, screen off). The `release` event // fires; we drop the stale sentinel and re-acquire when the page // becomes visible again while still wanted. // - request() rejects with NotAllowedError while hidden, in power // saver, or on low battery. The lock is best-effort: every failure // path is silent, and visibility regain re-attempts. // - Unsupported browsers (pre-126 Firefox, old iOS) simply never // acquire — no polyfill, no video-element hack. export type WakeLockSentinelLike = { release(): Promise; addEventListener(type: "release", listener: () => void): void; }; export type WakeLockAPILike = { request(type: "screen"): Promise; }; /** Minimal document surface used here — injectable for tests. */ export type WakeLockDoc = { visibilityState: DocumentVisibilityState; addEventListener(type: "visibilitychange", listener: () => void): void; }; function defaultAPI(): WakeLockAPILike | null { if (typeof navigator === "undefined" || !("wakeLock" in navigator)) return null; return (navigator as Navigator & { wakeLock?: WakeLockAPILike }).wakeLock ?? null; } export class WakeLockManager { private api: WakeLockAPILike | null; private doc: WakeLockDoc; private sentinel: WakeLockSentinelLike | null = null; private wanted = false; constructor(api: WakeLockAPILike | null = defaultAPI(), doc: WakeLockDoc = document) { this.api = api; this.doc = doc; doc.addEventListener("visibilitychange", () => this.syncVisibility()); } /** Whether a lock is currently held (exposed for tests). */ get held(): boolean { return this.sentinel !== null; } /** Begin wanting the screen awake. Safe to call repeatedly. */ async acquire(): Promise { this.wanted = true; if (!this.api || this.sentinel || this.doc.visibilityState !== "visible") return; try { const sentinel = await this.api.request("screen"); // A release()/hide can race the async request — drop a lock that // no longer serves the page state it was requested for. if (!this.wanted || this.doc.visibilityState !== "visible") { void sentinel.release().catch(() => {}); return; } sentinel.addEventListener("release", () => { // Browser-side release (page hidden, screen off). Drop the // handle; syncVisibility() re-acquires on visible while wanted. this.sentinel = null; }); this.sentinel = sentinel; } catch { // NotAllowedError etc — stay silent; syncVisibility() re-attempts // when the page becomes visible again, if still wanted. this.sentinel = null; } } /** Stop wanting the screen awake. Safe when never acquired. */ release(): void { this.wanted = false; const sentinel = this.sentinel; this.sentinel = null; void sentinel?.release().catch(() => {}); } /** * Re-acquire after a browser-side release once the page is visible * again (bound to visibilitychange in the constructor; exposed for * tests to drive directly). */ syncVisibility(): void { if (this.wanted && !this.sentinel && this.doc.visibilityState === "visible") { void this.acquire(); } } }