commit a5e8fc4480460ae26adb1ef2b980f6280a6fd31d truex <truex@equwal.com> 2026-09-26 18:53:42 -0700 Handle Back, the app state, the screen and player problems on Android - Back destroyed the activity, and the reader lost the book. Back now closes the page jump, else the drawer, else puts the reader in the background (App.minimizeApp). It never finishes the activity (backStep). On the web the browser handles Back. - In the background the clock stops, so no poll of the overlay runs, and the page goes to the meta data at once. Back in front, the clock reads the player at once, and the reader checks for a SubRead result and for the apps of the suite. - The screen stays on while a book is open, the player plays and the follow is not off (keepScreenOn). The plugin gets a call only when the value changes. - With SubRead Overlay installed but no notification access, play and "audio to page" stayed on, and the strip showed "PlayerError: no_notification_access". playerCommandsOff now turns both off for no_overlay, no_notification_access and, on the web, no_player. The strip gives an "Open SubRead Overlay" button for no notification access, and for an installed overlay that does not answer. Without the overlay, the link to its releases stays.
src/app-state.ts | 33 +++++++++++++++ src/main.ts | 106 +++++++++++++++++++++++++++++++++++++++++++++---- src/messages.ts | 56 +++++++++++++++++++++----- src/nav.ts | 6 +++ test/app-state.test.ts | 45 +++++++++++++++++++++ test/messages.test.ts | 34 ++++++++++++++++ 6 files changed, 262 insertions(+), 18 deletions(-)
diff --git a/src/app-state.ts b/src/app-state.ts new file mode 100644 index 0000000..b597355 --- /dev/null +++ b/src/app-state.ts @@ -0,0 +1,33 @@ +// What the Android Back button does, and when the screen stays on. Pure: no DOM. + +import type { FollowMode } from './follower'; + +/** + * The step of the Back button: close the page jump, close the drawer, or + * put the reader in the background. + */ +export type BackStep = 'close-jump' | 'close-drawer' | 'minimize'; + +/** + * The step of the Back button. The reader never finishes its activity: + * Android then destroys the WebView, and the reader loses its state. + */ +export function backStep(open: { jump: boolean; drawer: boolean }): BackStep { + if (open.jump) return 'close-jump'; + if (open.drawer) return 'close-drawer'; + return 'minimize'; +} + +/** + * True when the screen stays on: while the app shows, a book is open, the + * player plays and the follow is not off. During read-along the user does + * not touch the screen, so without this the screen turns off. + */ +export function keepScreenOn(s: { + active: boolean; + book: boolean; + playing: boolean; + mode: FollowMode; +}): boolean { + return s.active && s.book && s.playing && s.mode !== 'off'; +} diff --git a/src/main.ts b/src/main.ts index 38b71eb..d3057d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,9 @@ // are in drawer.ts (the menu), nav.ts (page turns by the user), status.ts // (the strip) and view.ts (the page). +import { App } from '@capacitor/app'; import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align'; +import { backStep, keepScreenOn } from './app-state'; import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from './book-subtitles'; import { bookText } from './book-text'; import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source'; @@ -27,6 +29,7 @@ import { pagesReadText, pdfLoadedText, pdfOpenText, + playerCommandsOff, playerMessage, readingEndMessage, readingText, @@ -47,7 +50,7 @@ import type { PdfDoc } from './pdf'; import { nextPage } from './read-order'; import { scrollTarget } from './scroll'; import { fill, onAction, say, showPlayer, showReading } from './status'; -import { isAndroid, SubRead, type SubtitlesResult } from './subread'; +import { isAndroid, SubRead, type SubtitlesResult, type SuiteApps } from './subread'; import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles'; import { bookKey, @@ -168,6 +171,12 @@ const state = { making: null as string | null, /** True while the player waits for a dictionary lookup that this app paused. */ lookupPaused: false, + /** False while the app is in the background. */ + active: true, + /** True while the plugin keeps the screen on. */ + awake: false, + /** The apps of the SubRead suite on the device, or null before the first answer. */ + suite: null as SuiteApps | null, /** True while a page turn by the user holds the follow. */ held: false, }; @@ -230,10 +239,11 @@ ui.rtl.addEventListener('change', () => { nav.update(); }); -// A new follow mode follows the audio at once. +// A new follow mode follows the audio at once. "Off" lets the screen turn off. ui.follow.addEventListener('change', () => { settings.set('follow', followMode()); runFollow('follow'); + updateAwake(); }); ui.pauseLookup.addEventListener('change', () => settings.set('pauseLookup', ui.pauseLookup.checked ? '1' : '0'), @@ -263,7 +273,7 @@ function setForceOcr(on: boolean): void { /** Shows a command that failed in the strip. */ function sayError(err: unknown): void { - say(errorMessage(err, isAndroid)); + say(errorMessage(err, isAndroid, overlayInstalled())); } // --- The PDF --- @@ -986,7 +996,7 @@ function scrollCueList(i: number): void { // --- The follow --- function showPlayerStatus(s: ClockState): void { - ui.play.disabled = s.error === 'no_overlay' || (!isAndroid && s.error === 'no_player'); + ui.play.disabled = playerCommandsOff(s.error, isAndroid); ui.play.classList.toggle('playing', s.playing); const action = s.playing ? 'Pause' : 'Play'; if (ui.play.title !== action) { @@ -994,11 +1004,20 @@ function showPlayerStatus(s: ClockState): void { ui.play.setAttribute('aria-label', action); } // Before a PDF is open, the start card tells what to do first. - showPlayer(state.pdf ? playerMessage(s, isAndroid) : null); + showPlayer(state.pdf ? playerMessage(s, isAndroid, overlayInstalled()) : null); updateSyncPage(); + updateAwake(); } -/** "Move the audio to this page" needs cues and a player with a position. */ +/** True when SubRead Overlay, the release or the debug build, is on the device. */ +function overlayInstalled(): boolean { + return state.suite !== null && (state.suite.overlay || state.suite.overlayDebug); +} + +/** + * "Move the audio to this page" needs cues and a player with a position. + * A player with a problem has no position that counts. + */ function updateSyncPage(): void { const s = state.clock; ui.syncPage.disabled = state.cues.length === 0 || !s || s.positionMs === null || s.error !== null; @@ -1067,6 +1086,73 @@ function seek(ms: number): void { const clock: ClockSource = isAndroid ? new OverlayClock(SubRead) : new LocalAudioClock(ui.audio); clock.start(applyClock); +// --- The screen and the app state --- + +/** Keeps the screen on during read-along (keepScreenOn). Calls the plugin only on a change. */ +function updateAwake(): void { + const on = keepScreenOn({ + active: state.active, + book: state.pdf !== null, + playing: state.clock?.playing === true, + mode: followMode(), + }); + if (on === state.awake) return; + state.awake = on; + SubRead.keepAwake({ on }).catch(() => undefined); +} + +/** + * The app goes to the background or comes back. In the background the + * clock stops, so no poll of the overlay runs, and the screen may turn + * off. Back in front, the clock reads the player at once. + */ +function setActive(active: boolean): void { + if (active === state.active) return; + state.active = active; + if (active) { + clock.start(applyClock); + if (isAndroid) { + void checkPendingSubtitles(); + void refreshSuite(); + } + } else { + clock.stop(); + // Android can stop the reader in the background. + flushPageSave(); + } + updateAwake(); +} + +void App.addListener('appStateChange', ({ isActive }) => setActive(isActive)); + +// Back never finishes the activity: that destroyed the WebView and the +// reader lost the book. On the web the browser handles Back. +if (isAndroid) { + void App.addListener('backButton', () => { + switch (backStep({ jump: nav.jumpOpen(), drawer: drawer.covers() })) { + case 'close-jump': + nav.closeJump(); + break; + case 'close-drawer': + drawer.set(false); + break; + case 'minimize': + void App.minimizeApp(); + break; + } + }); +} + +/** Asks which apps of the SubRead suite are installed. */ +async function refreshSuite(): Promise<void> { + try { + state.suite = await SubRead.suite(); + } catch { + return; + } + if (state.clock) showPlayerStatus(state.clock); +} + ui.play.addEventListener('click', () => { const action = state.clock?.playing ? clock.pause() : clock.play(); action.catch(sayError); @@ -1217,6 +1303,9 @@ onAction((id) => { case 'retry-reading': void readBook(true); break; + case 'open-overlay': + SubRead.openOverlay().catch(sayError); + break; } }); @@ -1231,6 +1320,9 @@ ui.audioRow.hidden = isAndroid; // The web copies a lookup at once: there is no dictionary app to choose or to wait for. ui.dictRow.hidden = !isAndroid; ui.pauseRow.hidden = !isAndroid; -if (isAndroid) void loadDictionaries().catch(sayError); +if (isAndroid) { + void loadDictionaries().catch(sayError); + void refreshSuite(); +} watchDb(); void restoreLastBook(); diff --git a/src/messages.ts b/src/messages.ts index 60a4a91..fec7795 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -3,7 +3,7 @@ import { PlayerError, type ClockState } from './clock-source'; /** The actions of the buttons in the strip. main.ts runs them. */ -export type ActionId = 'retry-reading'; +export type ActionId = 'retry-reading' | 'open-overlay'; /** A text, and a link or a button after it. */ export interface Message { @@ -28,11 +28,19 @@ export function clockText(ms: number): string { return `${h > 0 ? `${h}:` : ''}${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`; } +export const OPEN_OVERLAY = { id: 'open-overlay', text: 'Open SubRead Overlay' } as const; + /** * What is wrong with the player, and what to do, for a reason of * `PlayerError` or `ClockState.error`. Null for another reason. + * `overlayInstalled` is true when SubRead Overlay is on the device: then a + * button opens it, so that the user can give it notification access. */ -export function playerProblem(reason: string, android: boolean): Message | null { +export function playerProblem( + reason: string, + android: boolean, + overlayInstalled = false, +): Message | null { switch (reason) { case 'no_player': return { @@ -41,31 +49,57 @@ export function playerProblem(reason: string, android: boolean): Message | null : 'Load the audio file first.', }; case 'no_overlay': - return { - text: 'SubRead Overlay is not installed.', - link: { href: OVERLAY_RELEASES, text: 'Get it' }, - }; + return overlayInstalled + ? { text: 'SubRead Overlay does not answer.', action: OPEN_OVERLAY } + : { + text: 'SubRead Overlay is not installed.', + link: { href: OVERLAY_RELEASES, text: 'Get it' }, + }; case 'no_notification_access': - return { text: 'Allow notification access in SubRead Overlay.' }; + return { text: 'Allow notification access in SubRead Overlay.', action: OPEN_OVERLAY }; default: return null; } } +/** + * True when play and "audio to page" cannot work: no SubRead Overlay, no + * notification access, or on the web no audio file. + */ +export function playerCommandsOff(error: string | null, android: boolean): boolean { + return ( + error === 'no_overlay' || + error === 'no_notification_access' || + (!android && error === 'no_player') + ); +} + /** The text for a command that failed. A PlayerError gets the text of its reason. */ -export function errorMessage(err: unknown, android: boolean): Message { +export function errorMessage(err: unknown, android: boolean, overlayInstalled = false): Message { if (err instanceof PlayerError) { - return playerProblem(err.reason, android) ?? { text: `The player failed: ${err.reason}.` }; + return ( + playerProblem(err.reason, android, overlayInstalled) ?? { + text: `The player failed: ${err.reason}.`, + } + ); } return { text: String(err) }; } /** The player part of the strip: the time and the play state, or the problem. */ -export function playerMessage(s: ClockState | null, android: boolean): Message { +export function playerMessage( + s: ClockState | null, + android: boolean, + overlayInstalled = false, +): Message { const name = android ? 'Player' : 'Audio'; if (!s) return { text: `${name}: not read yet.` }; if (s.error !== null) { - return playerProblem(s.error, android) ?? { text: `${name}: cannot read it (${s.error}).` }; + return ( + playerProblem(s.error, android, overlayInstalled) ?? { + text: `${name}: cannot read it (${s.error}).`, + } + ); } if (s.positionMs === null) return { text: `${name}: no position.` }; return { text: `${name} ${clockText(s.positionMs)}, ${s.playing ? 'playing' : 'paused'}` }; diff --git a/src/nav.ts b/src/nav.ts index 0659027..48f6c88 100644 --- a/src/nav.ts +++ b/src/nav.ts @@ -36,6 +36,10 @@ export interface NavState { export interface Nav { /** Shows the page label, and the state and the names of the arrows. */ update(): void; + /** True while the page jump is open. */ + jumpOpen(): boolean; + /** Closes the page jump. */ + closeJump(): void; } function zoomed(): boolean { @@ -206,5 +210,7 @@ export function setupNav(ui: NavElements, s: NavState, turnTo: (index: number) = name(ui.pageRight, arrowStep('right', rtl)); if (pages === 0) closeJump(); }, + jumpOpen: () => !ui.pageInput.hidden, + closeJump, }; } diff --git a/test/app-state.test.ts b/test/app-state.test.ts new file mode 100644 index 0000000..0f6062b --- /dev/null +++ b/test/app-state.test.ts @@ -0,0 +1,45 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { backStep, keepScreenOn } from '../src/app-state'; +import type { FollowMode } from '../src/follower'; + +describe('backStep', () => { + it('closes the page jump first, then the drawer', () => { + expect(backStep({ jump: true, drawer: true })).toBe('close-jump'); + expect(backStep({ jump: true, drawer: false })).toBe('close-jump'); + expect(backStep({ jump: false, drawer: true })).toBe('close-drawer'); + }); + + it('puts the reader in the background, and never finishes it', () => { + // The phone finding: Back destroyed the activity, and the book was lost. + expect(backStep({ jump: false, drawer: false })).toBe('minimize'); + }); +}); + +describe('keepScreenOn', () => { + const on = { active: true, book: true, playing: true, mode: 'highlight' as FollowMode }; + + it('keeps the screen on during read-along, also in "Turn pages only"', () => { + expect(keepScreenOn(on)).toBe(true); + expect(keepScreenOn({ ...on, mode: 'pages' })).toBe(true); + }); + + it('lets the screen turn off when one condition is missing', () => { + const cases = fc.record({ + active: fc.boolean(), + book: fc.boolean(), + playing: fc.boolean(), + mode: fc.constantFrom<FollowMode>('highlight', 'pages', 'off'), + }); + fc.assert( + fc.property(cases, (s) => { + const all = s.active && s.book && s.playing && s.mode !== 'off'; + expect(keepScreenOn(s)).toBe(all); + }), + ); + expect(keepScreenOn({ ...on, active: false })).toBe(false); + expect(keepScreenOn({ ...on, book: false })).toBe(false); + expect(keepScreenOn({ ...on, playing: false })).toBe(false); + expect(keepScreenOn({ ...on, mode: 'off' })).toBe(false); + }); +}); diff --git a/test/messages.test.ts b/test/messages.test.ts index b9c0410..54bb67e 100644 --- a/test/messages.test.ts +++ b/test/messages.test.ts @@ -14,6 +14,7 @@ import { pagesReadText, pdfLoadedText, pdfOpenText, + playerCommandsOff, playerMessage, playerProblem, readingEndMessage, @@ -48,9 +49,27 @@ describe('playerProblem', () => { text: 'SubRead Overlay is not installed.', link: { href: OVERLAY_RELEASES, text: 'Get it' }, }); + }); + + it('gives a button that opens SubRead Overlay when it has no notification access', () => { + // The phone finding: the strip showed "PlayerError: no_notification_access". expect(playerProblem('no_notification_access', true)).toEqual({ text: 'Allow notification access in SubRead Overlay.', + action: { id: 'open-overlay', text: 'Open SubRead Overlay' }, + }); + }); + + it('gives the button, not the link, when SubRead Overlay is installed but does not answer', () => { + expect(playerProblem('no_overlay', true, true)).toEqual({ + text: 'SubRead Overlay does not answer.', + action: { id: 'open-overlay', text: 'Open SubRead Overlay' }, }); + expect(playerMessage(state({ positionMs: null, error: 'no_overlay' }), true, true)).toEqual( + playerProblem('no_overlay', true, true), + ); + expect(errorMessage(new PlayerError('no_overlay'), true, true)).toEqual( + playerProblem('no_overlay', true, true), + ); }); it('gives null for another reason', () => { @@ -58,6 +77,21 @@ describe('playerProblem', () => { }); }); +describe('playerCommandsOff', () => { + it('turns off play and "audio to page" without notification access', () => { + // The phone finding: with no notification access, play stayed on. + expect(playerCommandsOff('no_notification_access', true)).toBe(true); + expect(playerCommandsOff('no_overlay', true)).toBe(true); + }); + + it('turns them off on the web without an audio file only', () => { + expect(playerCommandsOff('no_player', false)).toBe(true); + expect(playerCommandsOff('no_player', true)).toBe(false); + expect(playerCommandsOff(null, true)).toBe(false); + expect(playerCommandsOff(null, false)).toBe(false); + }); +}); + describe('errorMessage', () => { it('gives the text of the reason for a PlayerError', () => { expect(errorMessage(new PlayerError('no_player'), false)).toEqual({