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