From afbf1029a967bd239792d53fe5d0d485aa155210 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 26 Oct 2026 13:59:37 -0400 Subject: [PATCH] feat(web): hold a Screen Wake Lock while reading or listening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- templates/audio_reader.templ | 1 + templates/audio_reader_templ.go | 115 +++++++++++--------- templates/reader.templ | 1 + templates/reader_templ.go | 11 +- web/src/audio.ts | 11 ++ web/src/reader/reader.ts | 9 ++ web/src/wake-lock.test.ts | 186 ++++++++++++++++++++++++++++++++ web/src/wake-lock.ts | 97 +++++++++++++++++ 8 files changed, 375 insertions(+), 56 deletions(-) create mode 100644 web/src/wake-lock.test.ts create mode 100644 web/src/wake-lock.ts diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index 09e349da..9dd5ef14 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -40,6 +40,7 @@ templ AudioReader(user User, metadata ReaderMetadata) { data-title={ metadata.Title } data-cover={ metadata.CoverImagePath } data-file-url={ metadata.FileURL } + data-keep-awake={ metadata.KeepScreenAwake } data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`, "/api/media-items/"+metadata.MediaItemID+"/chapters", "/api/media-items/"+metadata.MediaItemID+"/progress") } diff --git a/templates/audio_reader_templ.go b/templates/audio_reader_templ.go index cd19e757..991a5560 100644 --- a/templates/audio_reader_templ.go +++ b/templates/audio_reader_templ.go @@ -125,125 +125,138 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-json=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-keep-awake=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`, - "/api/media-items/"+metadata.MediaItemID+"/chapters", - "/api/media-items/"+metadata.MediaItemID+"/progress")) + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 45, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 43, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">
Player settings
Autoplay on open
Default playback speed ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, rate := range SpeedOptions { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if metadata.CoverImagePath != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 171, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" alt=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if metadata.CoverImagePath != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" alt=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 186, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "🎧") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "🎧") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 195, Col: 117} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

—

0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

Shortcuts & interactions
Mouse
Touch
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

—

0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

Shortcuts & interactions
Mouse
Touch
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/reader.templ b/templates/reader.templ index 07f38df0..66e33455 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -17,6 +17,7 @@ func readerInitExpr(metadata ReaderMetadata) string { "readingDirection": metadata.ReadingDirection, "mangaType": metadata.MangaType, "treatAsComic": metadata.TreatAsComic, + "keepScreenAwake": metadata.KeepScreenAwake, } jsonBytes, _ := json.Marshal(config) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) diff --git a/templates/reader_templ.go b/templates/reader_templ.go index d7187d56..b9879df5 100644 --- a/templates/reader_templ.go +++ b/templates/reader_templ.go @@ -25,6 +25,7 @@ func readerInitExpr(metadata ReaderMetadata) string { "readingDirection": metadata.ReadingDirection, "mangaType": metadata.MangaType, "treatAsComic": metadata.TreatAsComic, + "keepScreenAwake": metadata.KeepScreenAwake, } jsonBytes, _ := json.Marshal(config) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) @@ -58,7 +59,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 31, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 32, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -80,7 +81,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 41, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 42, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -175,7 +176,7 @@ func ReaderChrome(metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var7 templ.SafeURL templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 302, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 303, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -188,7 +189,7 @@ func ReaderChrome(metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 305, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 306, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -230,7 +231,7 @@ func drawerHeader(title string) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 577, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 578, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { diff --git a/web/src/audio.ts b/web/src/audio.ts index d1253dd7..b5a2fe76 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -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()); diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 48abbf5c..66464dd2 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -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 — diff --git a/web/src/wake-lock.test.ts b/web/src/wake-lock.test.ts new file mode 100644 index 00000000..59dfedea --- /dev/null +++ b/web/src/wake-lock.test.ts @@ -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 Promise>>; + 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; + + 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((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((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); + }); +}); diff --git a/web/src/wake-lock.ts b/web/src/wake-lock.ts new file mode 100644 index 00000000..45b4cfa2 --- /dev/null +++ b/web/src/wake-lock.ts @@ -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; + 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(); + } + } +}