feat(web): hold a Screen Wake Lock while reading or listening
Release / build-and-push (push) Successful in 3m12s

New wake-lock.ts engine (sanctioned island, dependency-injected for
tests): feature-detected navigator.wakeLock, silent no-op when
unsupported or denied, and the spec-mandated force-release-on-hide →
re-acquire-on-visible loop handled via the sentinel release event.
The player acquires on play and releases on pause (sleep-timer fades
ride the same path); the reader holds the lock for the session. Both
gate on the keep_screen_awake preference — data-keep-awake on the
player root, keepScreenAwake in readerInitExpr.

Hidden-tab playback intentionally does not keep the screen awake:
the spec forbids screen wake locks for hidden documents.
This commit is contained in:
John O'Keefe committed 2026-10-26 13:59:37 -04:00
1 parent c645b8e207
commit afbf1029a9
8 files changed
+375 -56

No files matched your search

+11
View File
@@ -15,6 +15,7 @@
import { Alpine } from "./alpine";
import { renderNoteHtml } from "./markdown";
import { showToast } from "./toast";
import { WakeLockManager } from "./wake-lock";
import {
MOUSE_GUIDE,
resolveShortcut,
@@ -288,6 +289,13 @@ Alpine.data("audioReader", () => ({
// 30s of a countdown. Reset by stop/finish.
sleepDim: 0,
// Screen wake lock (keep_screen_awake profile preference): held while
// playing so the display doesn't blank/lock mid-listen. The browser
// force-releases when the page hides; the manager re-acquires on
// visibility regain while audio is still going.
wakeLock: new WakeLockManager(),
keepAwakeEnabled: false,
// Settings popover (plan §4): header gear → panel. Autoplay-on-open
// is the first option; the panel is structured for more.
settingsOpen: false,
@@ -336,6 +344,7 @@ Alpine.data("audioReader", () => ({
const title = body.getAttribute("data-title") || "";
const cover = body.getAttribute("data-cover") || "";
const fileUrl = body.getAttribute("data-file-url") || "";
this.keepAwakeEnabled = body.getAttribute("data-keep-awake") === "true";
this.mediaItemId = mediaId;
this.title = title;
this.fileUrl = fileUrl;
@@ -413,6 +422,7 @@ Alpine.data("audioReader", () => ({
// is awake and asked for audio); stopSleep restores the volume.
if (this.sleep.mode === "fading") this.stopSleep();
this.updateMediaSessionState();
if (this.keepAwakeEnabled) void this.wakeLock.acquire();
});
audio.addEventListener("pause", () => {
const now = performance.now();
@@ -423,6 +433,7 @@ Alpine.data("audioReader", () => ({
this.playing = false;
this.pushProgress(true);
this.updateMediaSessionState();
if (this.keepAwakeEnabled) this.wakeLock.release();
});
window.addEventListener("pagehide", () => this.pushProgress(true));
window.addEventListener("resize", () => this.recomputeTicks());
+9
View File
@@ -7,6 +7,7 @@ import { getToken } from "../storage";
import { showToast } from "../toast";
import { renderNoteHtml, richPasteToMarkdown } from "../markdown";
import { extractPdfPages, searchPdfPages, type PdfPageText } from "./pdf-search";
import { WakeLockManager } from "../wake-lock";
const HIGHLIGHT_COLORS = ["#ffd54f", "#a5d6a7", "#90caf9", "#f48fb1", "#ce93d8"];
@@ -398,6 +399,10 @@ document.addEventListener("alpine:init", () => {
// comic reader treatment — bookmarks only, no text-selection
// highlights, no in-book search.
treatAsComic: false as boolean,
// Screen wake lock (keep_screen_awake profile preference): held for
// the whole reading session; the browser force-releases it when the
// page hides and the manager re-acquires on visibility regain.
wakeLock: new WakeLockManager(),
comicFlow: "paged" as string,
fxBrightness: 1 as number,
fxContrast: 1 as number,
@@ -627,9 +632,13 @@ document.addEventListener("alpine:init", () => {
readingDirection: string;
mangaType: string;
treatAsComic: boolean;
keepScreenAwake: boolean;
}) {
this.mediaItemId = config.mediaItemId;
this.treatAsComic = config.treatAsComic;
// Hold the display awake for the reading session (best-effort;
// unsupported browsers and power-saver denials are silent no-ops).
if (config.keepScreenAwake) void this.wakeLock.acquire();
// Reading state (position, bookmarks, annotations) is never baked
// into the rendered page: the web reader is intrinsically tied to
// the server, so it reads all of it from the APIs at open time —
+186
View File
@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
WakeLockManager,
type WakeLockAPILike,
type WakeLockDoc,
type WakeLockSentinelLike,
} from "./wake-lock";
// The manager is intentionally dependency-injected (api + doc) so every
// browser boundary is a fake here: sentinel lifecycles, the visibility
// state machine, and the force-release-then-reacquire loop the spec
// mandates when the page hides.
type FakeSentinel = WakeLockSentinelLike & {
release: ReturnType<typeof vi.fn<() => Promise<void>>>;
emitRelease: () => void;
};
function fakeSentinel(): FakeSentinel {
const listeners: Array<() => void> = [];
const sentinel: FakeSentinel = {
release: vi.fn(async () => {
sentinel.emitRelease();
}),
// Narrow, structural (not a bare vi.fn) to satisfy the interface.
addEventListener: (_type: "release", listener: () => void) => {
listeners.push(listener);
},
emitRelease: () => {
for (const l of listeners) l();
},
};
return sentinel;
}
function fakeDoc(initial: DocumentVisibilityState): WakeLockDoc & {
setVisible: (state: DocumentVisibilityState) => void;
fireVisibility: () => void;
} {
const listeners: Array<() => void> = [];
const doc = {
visibilityState: initial,
addEventListener: (_type: "visibilitychange", listener: () => void) => {
listeners.push(listener);
},
setVisible(state: DocumentVisibilityState) {
doc.visibilityState = state;
},
fireVisibility() {
for (const l of listeners) l();
},
};
return doc;
}
function fakeAPI(sentinel: FakeSentinel | null = fakeSentinel()) {
const request = vi.fn(async (_type: "screen") => {
if (!sentinel) throw new DOMException("denied", "NotAllowedError");
return sentinel;
});
return { request, sentinel };
}
describe("WakeLockManager", () => {
let doc: ReturnType<typeof fakeDoc>;
beforeEach(() => {
doc = fakeDoc("visible");
});
it("acquires a screen lock on acquire()", async () => {
const api = fakeAPI();
const manager = new WakeLockManager(api, doc);
await manager.acquire();
expect(api.request).toHaveBeenCalledWith("screen");
expect(manager.held).toBe(true);
});
it("is a silent no-op when the API is unsupported", async () => {
const manager = new WakeLockManager(null, doc);
await expect(manager.acquire()).resolves.toBeUndefined();
expect(manager.held).toBe(false);
});
it("does not double-request while a lock is held", async () => {
const api = fakeAPI();
const manager = new WakeLockManager(api, doc);
await manager.acquire();
await manager.acquire();
expect(api.request).toHaveBeenCalledTimes(1);
});
it("never requests while the page is hidden, but wants the lock on return", async () => {
doc.setVisible("hidden");
const api = fakeAPI();
const manager = new WakeLockManager(api, doc);
await manager.acquire();
expect(api.request).not.toHaveBeenCalled();
// Tab visible again — visibilitychange re-acquires while wanted.
doc.setVisible("visible");
manager.syncVisibility();
await vi.waitFor(() => expect(manager.held).toBe(true));
expect(api.request).toHaveBeenCalledTimes(1);
});
it("re-acquires after the browser force-releases on page hide", async () => {
const api = fakeAPI();
const manager = new WakeLockManager(api, doc);
await manager.acquire();
// Tab hides: browser releases the sentinel; the manager's release
// listener drops the stale handle (spec behavior, not ours).
doc.setVisible("hidden");
doc.fireVisibility();
api.sentinel!.emitRelease();
expect(manager.held).toBe(false);
// Tab returns with playback still going: lock comes back.
doc.setVisible("visible");
doc.fireVisibility();
await vi.waitFor(() => expect(manager.held).toBe(true));
expect(api.request).toHaveBeenCalledTimes(2);
});
it("release() drops the lock and stops re-acquisition", async () => {
const api = fakeAPI();
const manager = new WakeLockManager(api, doc);
await manager.acquire();
manager.release();
expect(api.sentinel!.release).toHaveBeenCalled();
expect(manager.held).toBe(false);
// Hide/return cycles do not bring the lock back once released —
// only a fresh acquire() (next play event) may.
doc.setVisible("hidden");
manager.syncVisibility();
doc.setVisible("visible");
manager.syncVisibility();
expect(api.request).toHaveBeenCalledTimes(1);
});
it("drops a lock that resolves after release() won the race", async () => {
const sentinel = fakeSentinel();
let resolveRequest: (s: FakeSentinel) => void = () => {};
const api: WakeLockAPILike = {
request: vi.fn(
() =>
new Promise<FakeSentinel>((resolve) => {
resolveRequest = resolve;
}),
),
};
const manager = new WakeLockManager(api, doc);
const acquiring = manager.acquire();
manager.release();
resolveRequest(sentinel);
await acquiring;
// The stale sentinel is released immediately, never adopted.
expect(sentinel.release).toHaveBeenCalled();
expect(manager.held).toBe(false);
});
it("absorbs NotAllowedError (power saver, low battery) silently", async () => {
const api = fakeAPI(null);
const manager = new WakeLockManager(api, doc);
await expect(manager.acquire()).resolves.toBeUndefined();
expect(manager.held).toBe(false);
});
it("drops a lock that resolves while the page went hidden", async () => {
const sentinel = fakeSentinel();
let resolveRequest: (s: FakeSentinel) => void = () => {};
const api: WakeLockAPILike = {
request: vi.fn(
() =>
new Promise<FakeSentinel>((resolve) => {
resolveRequest = resolve;
}),
),
};
const manager = new WakeLockManager(api, doc);
const acquiring = manager.acquire();
doc.setVisible("hidden");
resolveRequest(sentinel);
await acquiring;
expect(sentinel.release).toHaveBeenCalled();
expect(manager.held).toBe(false);
});
});
+97
View File
@@ -0,0 +1,97 @@
// 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<void>;
addEventListener(type: "release", listener: () => void): void;
};
export type WakeLockAPILike = {
request(type: "screen"): Promise<WakeLockSentinelLike>;
};
/** 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<void> {
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();
}
}
}