Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


commit 1d902099c567eb85fe19554694ef4269b238232d
truex <truex@equwal.com>
2026-09-26 13:43:55 -0700

Keep the screen on during read-along

During read-along the user does not touch the screen, so the screen
turns off. The new plugin method keepAwake sets or clears
FLAG_KEEP_SCREEN_ON on the window, on the UI thread. On the web, the
fallback holds a screen wake lock when the browser has the API. It asks
again when the browser released the lock, and it ignores errors.

 .../java/com/equwal/scansubread/SubReadPlugin.java | 27 ++++++++++
 src/subread.ts                                     | 25 ++++++++-
 test/subread.test.ts                               | 59 ++++++++++++++++++++++
 3 files changed, 110 insertions(+), 1 deletion(-)
diff --git a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
index f568ab9..d472b4a 100644
--- a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
+++ b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
@@ -14,6 +14,7 @@ import android.database.Cursor;
 import android.net.Uri;
 import android.os.Bundle;
 import android.provider.OpenableColumns;
+import android.view.WindowManager;
 import androidx.activity.result.ActivityResult;
 import androidx.core.content.FileProvider;
 import com.getcapacitor.JSArray;
@@ -44,6 +45,7 @@ import java.util.List;
  *   menu. The lookup resolves when the dictionary closes.
  * - The subtitle maker, through the intent API of the SubRead app
  *   (`space.subread.app.action.ALIGN`).
+ * - The screen, which stays on during read-along.
  */
 @CapacitorPlugin(name = "SubRead")
 public class SubReadPlugin extends Plugin {
@@ -154,6 +156,31 @@ public class SubReadPlugin extends Plugin {
         call.resolve(lineObject(callPlayer("seek", String.valueOf(Math.max(0, ms)))));
     }
 
+    // --- The screen ---
+
+    /**
+     * Keeps the screen on, or lets it turn off again. During read-along the
+     * user does not touch the screen, so without this the screen turns off.
+     */
+    @PluginMethod
+    public void keepAwake(PluginCall call) {
+        Boolean on = call.getBoolean("on");
+        if (on == null) {
+            call.reject("on is required.");
+            return;
+        }
+        // Only the UI thread can change the flags of the window.
+        getActivity()
+            .runOnUiThread(() -> {
+                if (on) {
+                    getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+                } else {
+                    getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+                }
+                call.resolve();
+            });
+    }
+
     // --- The dictionary, through ACTION_PROCESS_TEXT ---
 
     private SharedPreferences prefs() {
diff --git a/src/subread.ts b/src/subread.ts
index 6a104bd..f3b4f9a 100644
--- a/src/subread.ts
+++ b/src/subread.ts
@@ -1,7 +1,8 @@
 // The bridge to the SubRead suite: the typed side of SubReadPlugin.java.
 //
 // On the web there is no suite. The fallback reports no overlay, copies a
-// lookup to the clipboard, and cannot make subtitles.
+// lookup to the clipboard, and cannot make subtitles. It keeps the screen on
+// with the Screen Wake Lock API when the browser has it.
 
 import { Capacitor, registerPlugin } from '@capacitor/core';
 
@@ -32,6 +33,12 @@ export interface SubReadPlugin {
   play(): Promise<StateLine>;
   pause(): Promise<StateLine>;
   seek(options: { ms: number }): Promise<StateLine>;
+  /**
+   * Keeps the screen on while `on` is true. During read-along the user does
+   * not touch the screen, so without this the screen turns off. On the web,
+   * a screen wake lock where the browser has one; errors are ignored.
+   */
+  keepAwake(options: { on: boolean }): Promise<void>;
   /** Opens the dictionary with the text. Resolves when the dictionary closes. */
   lookup(options: { text: string }): Promise<{ closed: boolean }>;
   dictionaries(): Promise<{ apps: DictionaryApp[]; chosen: string }>;
@@ -47,6 +54,9 @@ export interface SubReadPlugin {
 }
 
 class SubReadWeb implements SubReadPlugin {
+  /** The screen wake lock, while one is held. */
+  private wakeLock: WakeLockSentinel | null = null;
+
   private noOverlay(): Promise<StateLine> {
     return Promise.resolve({ line: 'error=no_overlay' });
   }
@@ -62,6 +72,19 @@ class SubReadWeb implements SubReadPlugin {
   seek(): Promise<StateLine> {
     return this.noOverlay();
   }
+  async keepAwake({ on }: { on: boolean }): Promise<void> {
+    try {
+      if (!on) {
+        const lock = this.wakeLock;
+        this.wakeLock = null;
+        await lock?.release();
+      } else if ('wakeLock' in navigator && (this.wakeLock === null || this.wakeLock.released)) {
+        this.wakeLock = await navigator.wakeLock.request('screen');
+      }
+    } catch {
+      // No wake lock here: a hidden page, a page without HTTPS, or a refusal.
+    }
+  }
   async lookup({ text }: { text: string }): Promise<{ closed: boolean }> {
     try {
       await navigator.clipboard.writeText(text);
diff --git a/test/subread.test.ts b/test/subread.test.ts
new file mode 100644
index 0000000..1bac26e
--- /dev/null
+++ b/test/subread.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { SubRead } from '../src/subread';
+
+/** A fake Screen Wake Lock API that keeps each lock it gives. */
+function fakeWakeLock() {
+  const locks: { released: boolean; release: () => Promise<void> }[] = [];
+  const request = vi.fn(async (type: string) => {
+    expect(type).toBe('screen');
+    const lock = {
+      released: false,
+      release: async () => {
+        lock.released = true;
+      },
+    };
+    locks.push(lock);
+    return lock;
+  });
+  return { wakeLock: { request }, request, locks };
+}
+
+describe('the web fallback of the plugin', () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it('holds one screen wake lock while keepAwake is on', async () => {
+    const { wakeLock, request, locks } = fakeWakeLock();
+    vi.stubGlobal('navigator', { wakeLock });
+    await SubRead.keepAwake({ on: true });
+    await SubRead.keepAwake({ on: true });
+    expect(request).toHaveBeenCalledTimes(1);
+    await SubRead.keepAwake({ on: false });
+    expect(locks[0]!.released).toBe(true);
+    await SubRead.keepAwake({ on: false });
+    expect(request).toHaveBeenCalledTimes(1);
+  });
+
+  it('asks again for a lock that the browser released', async () => {
+    const { wakeLock, request, locks } = fakeWakeLock();
+    vi.stubGlobal('navigator', { wakeLock });
+    await SubRead.keepAwake({ on: true });
+    // The browser releases the lock when the page is hidden.
+    locks[0]!.released = true;
+    await SubRead.keepAwake({ on: true });
+    expect(request).toHaveBeenCalledTimes(2);
+    await SubRead.keepAwake({ on: false });
+    expect(locks[1]!.released).toBe(true);
+  });
+
+  it('ignores a browser with no wake lock, and a refused request', async () => {
+    vi.stubGlobal('navigator', {});
+    await expect(SubRead.keepAwake({ on: true })).resolves.toBeUndefined();
+    const refuse = vi.fn(() => Promise.reject(new Error('NotAllowedError')));
+    vi.stubGlobal('navigator', { wakeLock: { request: refuse } });
+    await expect(SubRead.keepAwake({ on: true })).resolves.toBeUndefined();
+    expect(refuse).toHaveBeenCalledTimes(1);
+    await expect(SubRead.keepAwake({ on: false })).resolves.toBeUndefined();
+  });
+});