commit 17aaf0ddbb5138903d85de945aa2f556837e3fe0
truex <truex@equwal.com>
2026-09-26 18:56:48 -0700
Show the SubRead suite checklist on the start card and in the menu
On Android the start card and the menu ("SubRead suite") list the apps
of the suite from SubRead.suite():
- SubRead Overlay: installed or not, and the notification access from
the last player state, with an "Open SubRead Overlay" button.
- The dictionary apps: their count. With none, a link to the releases
of SubRead Dictionary.
- SubRead: its version, or a link to its releases.
- SubRead Anki: installed, or a link to its releases.
The list changes when the app is active again and when the error of
the player changes. The web shows no list. suiteChecklist is pure and
has tests.
index.html | 5 +++
src/main.ts | 38 ++++++++++++++++++++++-
src/style.css | 32 ++++++++++++++++++++
src/suite.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/suite.test.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 249 insertions(+), 1 deletion(-)
diff --git a/index.html b/index.html
index 598d06a..d316283 100644
--- a/index.html
+++ b/index.html
@@ -144,6 +144,11 @@
<button id="clear-all">Clear all pages</button>
</div>
+ <section id="suite" hidden>
+ <h2>SubRead suite</h2>
+ <ul id="suite-list" class="suite"></ul>
+ </section>
+
<ol id="cues"></ol>
</aside>
diff --git a/src/main.ts b/src/main.ts
index d3057d7..d7559cf 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -51,6 +51,7 @@ import { nextPage } from './read-order';
import { scrollTarget } from './scroll';
import { fill, onAction, say, showPlayer, showReading } from './status';
import { isAndroid, SubRead, type SubtitlesResult, type SuiteApps } from './subread';
+import { suiteChecklist } from './suite';
import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles';
import {
bookKey,
@@ -113,6 +114,9 @@ const ui = {
pauseLookup: el<HTMLInputElement>('pause-lookup'),
clearBook: el<HTMLButtonElement>('clear-book'),
clearAll: el<HTMLButtonElement>('clear-all'),
+ suite: el<HTMLElement>('suite'),
+ suiteList: el<HTMLUListElement>('suite-list'),
+ suiteChecklist: el<HTMLDivElement>('suite-checklist'),
cues: el<HTMLOListElement>('cues'),
pageLeft: el<HTMLButtonElement>('page-left'),
pageRight: el<HTMLButtonElement>('page-right'),
@@ -177,6 +181,8 @@ const state = {
awake: false,
/** The apps of the SubRead suite on the device, or null before the first answer. */
suite: null as SuiteApps | null,
+ /** The player error that the checklist shows. Undefined before the first player state. */
+ suiteError: undefined as string | null | undefined,
/** True while a page turn by the user holds the follow. */
held: false,
};
@@ -1007,6 +1013,8 @@ function showPlayerStatus(s: ClockState): void {
showPlayer(state.pdf ? playerMessage(s, isAndroid, overlayInstalled()) : null);
updateSyncPage();
updateAwake();
+ // The checklist shows the notification access of the last player state.
+ if (s.error !== state.suiteError) renderSuite();
}
/** True when SubRead Overlay, the release or the debug build, is on the device. */
@@ -1143,16 +1151,44 @@ if (isAndroid) {
});
}
-/** Asks which apps of the SubRead suite are installed. */
+/** Asks which apps of the SubRead suite are installed, and shows the checklist. */
async function refreshSuite(): Promise<void> {
try {
state.suite = await SubRead.suite();
} catch {
return;
}
+ renderSuite();
if (state.clock) showPlayerStatus(state.clock);
}
+/**
+ * Shows the checklist of the SubRead suite on the start card and in the
+ * menu (Android only). The notification access of SubRead Overlay comes
+ * from the last player state.
+ */
+function renderSuite(): void {
+ const apps = state.suite;
+ if (!isAndroid || !apps) return;
+ state.suiteError = state.clock?.error;
+ const items = suiteChecklist(apps, state.suiteError);
+ const list = (): HTMLLIElement[] =>
+ items.map((item) => {
+ const li = document.createElement('li');
+ li.className = item.ok ? 'ok' : 'missing';
+ fill(li, item);
+ return li;
+ });
+ ui.suiteList.replaceChildren(...list());
+ const title = document.createElement('h3');
+ title.textContent = 'SubRead suite';
+ const card = document.createElement('ul');
+ card.className = 'suite';
+ card.append(...list());
+ ui.suiteChecklist.replaceChildren(title, card);
+ ui.suite.hidden = false;
+}
+
ui.play.addEventListener('click', () => {
const action = state.clock?.playing ? clock.pause() : clock.play();
action.catch(sayError);
diff --git a/src/style.css b/src/style.css
index 9f3adfe..d8ad45d 100644
--- a/src/style.css
+++ b/src/style.css
@@ -371,6 +371,38 @@ body.zoomed #viewer {
display: none;
}
+#suite-checklist h3,
+#suite h2 {
+ margin: 0 0 0.3rem;
+ font-size: 0.95rem;
+}
+
+/* The checklist of the SubRead suite: a mark, the text, then a link or a button. */
+.suite {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ font-size: 0.85rem;
+}
+
+.suite li::before {
+ content: '✓';
+ display: inline-block;
+ width: 1.3em;
+ font-weight: 700;
+}
+
+.suite li.missing::before {
+ content: '✗';
+}
+
+.suite a {
+ color: inherit;
+}
+
/*
* The page fits the viewer width in each orientation, up to 1000 CSS
* pixels on a wide screen. The viewer scrolls vertically.
diff --git a/src/suite.ts b/src/suite.ts
new file mode 100644
index 0000000..a4328ce
--- /dev/null
+++ b/src/suite.ts
@@ -0,0 +1,89 @@
+// The checklist of the SubRead suite: which apps are on the device, and
+// what to do for an app that is missing. Pure: no DOM.
+
+import { OPEN_OVERLAY, OVERLAY_RELEASES, SUBREAD_RELEASES, type Message } from './messages';
+import type { SuiteApps } from './subread';
+
+export const DICTIONARY_RELEASES = 'https://github.com/equwal/subread-dictionary/releases/latest';
+export const ANKI_RELEASES = 'https://github.com/equwal/subread-anki/releases/latest';
+
+/** One line of the checklist. `ok` is true when the app is ready. */
+export interface ChecklistItem extends Message {
+ ok: boolean;
+}
+
+/**
+ * The line of SubRead Overlay. `playerError` is the error of the last
+ * player state, undefined before the first state. A state or `no_player`
+ * shows that the overlay has notification access.
+ */
+function overlayItem(apps: SuiteApps, playerError: string | null | undefined): ChecklistItem {
+ if (!apps.overlay && !apps.overlayDebug) {
+ return {
+ ok: false,
+ text: 'SubRead Overlay: not installed.',
+ link: { href: OVERLAY_RELEASES, text: 'Get SubRead Overlay' },
+ };
+ }
+ switch (playerError) {
+ case 'no_notification_access':
+ return { ok: false, text: 'SubRead Overlay: no notification access.', action: OPEN_OVERLAY };
+ case 'no_overlay':
+ return { ok: false, text: 'SubRead Overlay: does not answer.', action: OPEN_OVERLAY };
+ case null:
+ case 'no_player':
+ return {
+ ok: true,
+ text: 'SubRead Overlay: installed, with notification access.',
+ action: OPEN_OVERLAY,
+ };
+ default:
+ return { ok: true, text: 'SubRead Overlay: installed.', action: OPEN_OVERLAY };
+ }
+}
+
+function dictionaryItem(count: number): ChecklistItem {
+ if (count > 0) return { ok: true, text: `Dictionary apps: ${count}.` };
+ return {
+ ok: false,
+ text: 'Dictionary apps: none.',
+ link: { href: DICTIONARY_RELEASES, text: 'Get SubRead Dictionary' },
+ };
+}
+
+function subreadItem(version: string | null): ChecklistItem {
+ if (version === null) {
+ return {
+ ok: false,
+ text: 'SubRead: not installed.',
+ link: { href: SUBREAD_RELEASES, text: 'Get SubRead' },
+ };
+ }
+ return { ok: true, text: version ? `SubRead ${version}: installed.` : 'SubRead: installed.' };
+}
+
+function ankiItem(installed: boolean): ChecklistItem {
+ if (installed) return { ok: true, text: 'SubRead Anki: installed.' };
+ return {
+ ok: false,
+ text: 'SubRead Anki: not installed.',
+ link: { href: ANKI_RELEASES, text: 'Get SubRead Anki' },
+ };
+}
+
+/**
+ * The checklist: SubRead Overlay, the dictionary apps, SubRead and SubRead
+ * Anki. `playerError` is the error of the last player state, undefined
+ * before the first state.
+ */
+export function suiteChecklist(
+ apps: SuiteApps,
+ playerError: string | null | undefined,
+): ChecklistItem[] {
+ return [
+ overlayItem(apps, playerError),
+ dictionaryItem(apps.dictionaries),
+ subreadItem(apps.subread),
+ ankiItem(apps.anki),
+ ];
+}
diff --git a/test/suite.test.ts b/test/suite.test.ts
new file mode 100644
index 0000000..b9d04ce
--- /dev/null
+++ b/test/suite.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest';
+import { OVERLAY_RELEASES, SUBREAD_RELEASES } from '../src/messages';
+import type { SuiteApps } from '../src/subread';
+import { ANKI_RELEASES, DICTIONARY_RELEASES, suiteChecklist } from '../src/suite';
+
+const openOverlay = { id: 'open-overlay', text: 'Open SubRead Overlay' };
+
+const none: SuiteApps = {
+ overlay: false,
+ overlayDebug: false,
+ subread: null,
+ anki: false,
+ dictionaries: 0,
+};
+
+describe('suiteChecklist', () => {
+ it('links each missing app to its releases', () => {
+ expect(suiteChecklist(none, undefined)).toEqual([
+ {
+ ok: false,
+ text: 'SubRead Overlay: not installed.',
+ link: { href: OVERLAY_RELEASES, text: 'Get SubRead Overlay' },
+ },
+ {
+ ok: false,
+ text: 'Dictionary apps: none.',
+ link: { href: DICTIONARY_RELEASES, text: 'Get SubRead Dictionary' },
+ },
+ {
+ ok: false,
+ text: 'SubRead: not installed.',
+ link: { href: SUBREAD_RELEASES, text: 'Get SubRead' },
+ },
+ {
+ ok: false,
+ text: 'SubRead Anki: not installed.',
+ link: { href: ANKI_RELEASES, text: 'Get SubRead Anki' },
+ },
+ ]);
+ });
+
+ it('shows the phone of the test: the overlay without notification access', () => {
+ const phone: SuiteApps = {
+ overlay: true,
+ overlayDebug: false,
+ subread: '0.9.1',
+ anki: true,
+ dictionaries: 3,
+ };
+ expect(suiteChecklist(phone, 'no_notification_access')).toEqual([
+ { ok: false, text: 'SubRead Overlay: no notification access.', action: openOverlay },
+ { ok: true, text: 'Dictionary apps: 3.' },
+ { ok: true, text: 'SubRead 0.9.1: installed.' },
+ { ok: true, text: 'SubRead Anki: installed.' },
+ ]);
+ });
+
+ it('takes the notification access of the overlay from the last player state', () => {
+ const debug = { ...none, overlayDebug: true };
+ const overlay = (error: string | null | undefined) => suiteChecklist(debug, error)[0];
+ expect(overlay(undefined)).toEqual({
+ ok: true,
+ text: 'SubRead Overlay: installed.',
+ action: openOverlay,
+ });
+ const access = {
+ ok: true,
+ text: 'SubRead Overlay: installed, with notification access.',
+ action: openOverlay,
+ };
+ expect(overlay(null)).toEqual(access);
+ expect(overlay('no_player')).toEqual(access);
+ expect(overlay('no_overlay')).toEqual({
+ ok: false,
+ text: 'SubRead Overlay: does not answer.',
+ action: openOverlay,
+ });
+ });
+
+ it('names SubRead without a version name', () => {
+ expect(suiteChecklist({ ...none, subread: '' }, undefined)[2]).toEqual({
+ ok: true,
+ text: 'SubRead: installed.',
+ });
+ });
+});