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