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

+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);
});
});