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


src/subread.ts (8704 bytes)

1 // The bridge to the SubRead suite: the typed side of SubReadPlugin.java.
2 //
3 // On the web there is no suite. The fallback reports no overlay, copies a
4 // lookup to the clipboard, and cannot make subtitles or cards. It keeps the
5 // screen on with the Screen Wake Lock API when the browser has it.
6 
7 import { Capacitor, registerPlugin } from '@capacitor/core';
8 
9 export interface StateLine {
10   /** One state line of SubRead Overlay, or `error=<reason>`. */
11   line: string;
12 }
13 
14 export interface DictionaryApp {
15   label: string;
16   /** A flattened ComponentName. */
17   component: string;
18 }
19 
20 export interface SubtitlesResult {
21   srt?: string;
22   /** The number of cues. */
23   cues?: number;
24   /** 0 to 1: the share of lines whose words were found in the book. */
25   matchRate?: number;
26   language?: string | null;
27   /**
28    * `not_installed`, `cancelled`, `cannot_start: <reason>` when Android
29    * cannot start SubRead, or the reason SubRead gave.
30    */
31   error?: string;
32 }
33 
34 /** A card for SubRead Anki. Give `word` or `text`. */
35 export interface AnkiCard {
36   /** The word, in its dictionary form when the caller knows it. */
37   word?: string;
38   /** The reading of the word, in kana. */
39   reading?: string;
40   /** The sentence that holds the word, as plain text: see `sentenceAround`. */
41   sentence?: string;
42   /** A text with no word chosen: the user taps the word on the card. */
43   text?: string;
44   /** Where the sentence is from, for example the title of the book. */
45   source?: string;
46   /** True: show the card before it goes to Anki. */
47   show?: boolean;
48 }
49 
50 export interface AnkiResult {
51   /** True when the note is in Anki. False when the user closed the card. */
52   added?: boolean;
53   /** The id of the new note in Anki, when `added` is true. */
54   noteId?: number;
55   /** SubRead Anki is not on the device, or Android cannot start it. */
56   error?: 'not_installed' | 'cannot_start';
57 }
58 
59 /** The apps of the SubRead suite on the device. */
60 export interface SuiteApps {
61   /** SubRead Overlay, the release build. */
62   overlay: boolean;
63   /** SubRead Overlay, the debug build. */
64   overlayDebug: boolean;
65   /** The version name of SubRead, for example "0.10.0", or null without SubRead. */
66   subread: string | null;
67   /** SubRead Anki, the release build or the debug build. */
68   anki: boolean;
69   /** The number of apps in the text selection menu (the `dictionaries` list), without this app. */
70   dictionaries: number;
71 }
72 
73 export interface SubReadPlugin {
74   playerState(): Promise<StateLine>;
75   play(): Promise<StateLine>;
76   pause(): Promise<StateLine>;
77   seek(options: { ms: number }): Promise<StateLine>;
78   /**
79    * Keeps the screen on while `on` is true. During read-along the user does
80    * not touch the screen, so without this the screen turns off. On the web,
81    * a screen wake lock where the browser has one; errors are ignored.
82    */
83   keepAwake(options: { on: boolean }): Promise<void>;
84   /**
85    * Opens the dictionary with the text. Resolves when the dictionary closes.
86    * A dictionary that opens in its own task answers at once, so for such an
87    * answer the call waits until the user is back in the reader, or 1.5 s
88    * when the dictionary did not open. Rejects when Android cannot open the
89    * dictionary or the chooser.
90    */
91   lookup(options: { text: string }): Promise<{ closed: boolean }>;
92   dictionaries(): Promise<{ apps: DictionaryApp[]; chosen: string }>;
93   /** An empty component means: ask each time. */
94   setDictionary(options: { component: string }): Promise<void>;
95   /**
96    * Makes a card in SubRead Anki (`space.subread.anki.action.ADD`): the
97    * release build, else the debug build. Only the fields that are given and
98    * not empty go to SubRead Anki; without `word`
99    * and `text` the call rejects. Resolves when SubRead Anki closes, the same
100    * as `lookup`: `{ added: true, noteId }` when the note is in Anki,
101    * `{ added: false }` when the user closed the card,
102    * `{ error: 'not_installed' }` without SubRead Anki, and
103    * `{ error: 'cannot_start' }` when Android cannot start SubRead Anki. On
104    * the web: `{ error: 'not_installed' }`.
105    */
106   ankiAdd(options: AnkiCard): Promise<AnkiResult>;
107   /**
108    * Tells which apps of the SubRead suite are installed, for a checklist.
109    * On the web: all false, null and 0.
110    */
111   suite(): Promise<SuiteApps>;
112   /**
113    * Opens SubRead Overlay (the release build, else the debug build), so that
114    * the user can give it notification access. Without SubRead Overlay, opens
115    * the notification access settings of Android. Resolves which one opened.
116    * Rejects when Android cannot open it. On the web: rejects with
117    * Error('Android only.').
118    */
119   openOverlay(): Promise<{ opened: 'overlay' | 'settings' }>;
120   /**
121    * Asks SubRead for the .srt of the audio and the book text
122    * (`space.subread.app.action.ALIGN`), and resolves with the answer of
123    * SubRead.
124    *
125    * `resultName` names a result file for the book, for example a name that
126    * the caller makes from the book key. The plugin keeps A-Z, a-z, 0-9, ".",
127    * "_" and "-" of the name, and changes each other character to "_". The
128    * call removes the result of an earlier job with the same name. SubRead
129    * 0.10.0 and later write the .srt into the file before they answer, so
130    * the .srt is safe when Android stops the reader during the job. Older
131    * versions ignore the file. See `pendingSubtitles`.
132    */
133   makeSubtitles(options: {
134     audio: string;
135     bookText: string;
136     language: string;
137     resultName?: string;
138   }): Promise<SubtitlesResult>;
139   /**
140    * The .srt that SubRead wrote into the result file `resultName` of
141    * `makeSubtitles`, or `{}` when the file is not there or is empty. The
142    * call removes the file after it reads it. Call it when the book opens,
143    * for a job that ended while the reader was stopped. The file stays also
144    * after a normal answer of `makeSubtitles`, so call it then too, and use
145    * one of the two copies. On the web: `{}`.
146    */
147   pendingSubtitles(options: { resultName: string }): Promise<{ srt?: string }>;
148   /** Rejects when the user picks no file, or when Android cannot open the file chooser. */
149   pickAudio(): Promise<{ uri: string; name: string }>;
150   /** Rejects when Android cannot open the share sheet. */
151   shareText(options: { name: string; text: string }): Promise<void>;
152 }
153 
154 class SubReadWeb implements SubReadPlugin {
155   /** The screen wake lock, while one is held. */
156   private wakeLock: WakeLockSentinel | null = null;
157 
158   private noOverlay(): Promise<StateLine> {
159     return Promise.resolve({ line: 'error=no_overlay' });
160   }
161   playerState(): Promise<StateLine> {
162     return this.noOverlay();
163   }
164   play(): Promise<StateLine> {
165     return this.noOverlay();
166   }
167   pause(): Promise<StateLine> {
168     return this.noOverlay();
169   }
170   seek(): Promise<StateLine> {
171     return this.noOverlay();
172   }
173   async keepAwake({ on }: { on: boolean }): Promise<void> {
174     try {
175       if (!on) {
176         const lock = this.wakeLock;
177         this.wakeLock = null;
178         await lock?.release();
179       } else if ('wakeLock' in navigator && (this.wakeLock === null || this.wakeLock.released)) {
180         this.wakeLock = await navigator.wakeLock.request('screen');
181       }
182     } catch {
183       // No wake lock here: a hidden page, a page without HTTPS, or a refusal.
184     }
185   }
186   async lookup({ text }: { text: string }): Promise<{ closed: boolean }> {
187     try {
188       await navigator.clipboard.writeText(text);
189     } catch {
190       // No clipboard in this context. The status line still shows the text.
191     }
192     return { closed: true };
193   }
194   dictionaries(): Promise<{ apps: DictionaryApp[]; chosen: string }> {
195     return Promise.resolve({ apps: [], chosen: '' });
196   }
197   setDictionary(): Promise<void> {
198     return Promise.resolve();
199   }
200   ankiAdd(): Promise<AnkiResult> {
201     return Promise.resolve({ error: 'not_installed' });
202   }
203   suite(): Promise<SuiteApps> {
204     return Promise.resolve({
205       overlay: false,
206       overlayDebug: false,
207       subread: null,
208       anki: false,
209       dictionaries: 0,
210     });
211   }
212   openOverlay(): Promise<{ opened: 'overlay' | 'settings' }> {
213     return Promise.reject(new Error('Android only.'));
214   }
215   makeSubtitles(): Promise<SubtitlesResult> {
216     return Promise.resolve({ error: 'not_installed' });
217   }
218   pendingSubtitles(): Promise<{ srt?: string }> {
219     return Promise.resolve({});
220   }
221   pickAudio(): Promise<{ uri: string; name: string }> {
222     return Promise.reject(new Error('Android only.'));
223   }
224   shareText(): Promise<void> {
225     return Promise.reject(new Error('Android only.'));
226   }
227 }
228 
229 export const isAndroid = Capacitor.getPlatform() === 'android';
230 
231 export const SubRead = registerPlugin<SubReadPlugin>('SubRead', {
232   web: () => new SubReadWeb(),
233 });