src/messages.ts (12101 bytes)
1 // The texts of the status strip. Pure: no DOM. 2 3 import { PlayerError, type ClockState } from './clock-source'; 4 5 /** The actions of the buttons in the strip. main.ts runs them. */ 6 export type ActionId = 'retry-reading' | 'open-overlay'; 7 8 /** A text, and a link or a button after it. */ 9 export interface Message { 10 text: string; 11 link?: { href: string; text: string }; 12 action?: { id: ActionId; text: string }; 13 } 14 15 /** A part of the status strip, for the panel of its full texts. */ 16 export interface StripPart { 17 /** False while the part is hidden. */ 18 shown: boolean; 19 /** The full text of its message: its title attribute, or '' when it has none. */ 20 title: string; 21 /** Its text as it shows, with the text of its link or its button. */ 22 text: string; 23 } 24 25 /** 26 * The full texts of the parts of the strip that show, for the panel that a 27 * tap on the strip opens: the title of each part, else its text. A part 28 * with no text gives nothing. On the phone the one-line strip cut a long 29 * message, and a phone cannot hover to read the title. 30 */ 31 export function stripTexts(parts: readonly StripPart[]): string[] { 32 return parts 33 .filter((part) => part.shown) 34 .map((part) => part.title || part.text.trim()) 35 .filter((text) => text !== ''); 36 } 37 38 export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest'; 39 export const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest'; 40 export const DICTIONARY_RELEASES = 'https://github.com/equwal/subread-dictionary/releases/latest'; 41 export const ANKI_RELEASES = 'https://github.com/equwal/subread-anki/releases/latest'; 42 43 /** A long press on the web: there is no SubRead Anki. */ 44 export const ANKI_WEB_TEXT = 'Anki cards need SubRead Anki on Android.'; 45 46 /** The result of SubRead Anki in the strip. Null when the user closed the card. */ 47 export function ankiText(result: { added?: boolean; error?: string }): Message | null { 48 if (result.error === 'not_installed') { 49 return { 50 text: 'SubRead Anki is not installed.', 51 link: { href: ANKI_RELEASES, text: 'Get SubRead Anki' }, 52 }; 53 } 54 if (result.error !== undefined) return { text: 'SubRead Anki could not start.' }; 55 return result.added ? { text: 'Card added to Anki.' } : null; 56 } 57 58 /** "1 page", "2 pages". */ 59 function count(n: number, noun: string): string { 60 return `${n} ${noun}${n === 1 ? '' : 's'}`; 61 } 62 63 /** A position as mm:ss, or h:mm:ss from one hour. */ 64 export function clockText(ms: number): string { 65 const s = Math.floor(Math.max(0, ms) / 1000); 66 const pad = (n: number): string => String(n).padStart(2, '0'); 67 const h = Math.floor(s / 3600); 68 return `${h > 0 ? `${h}:` : ''}${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`; 69 } 70 71 export const OPEN_OVERLAY = { id: 'open-overlay', text: 'Open SubRead Overlay' } as const; 72 73 /** 74 * What is wrong with the player, and what to do, for a reason of 75 * `PlayerError` or `ClockState.error`. Null for another reason. 76 * `overlayInstalled` is true when SubRead Overlay is on the device: then a 77 * button opens it, so that the user can give it notification access. 78 */ 79 export function playerProblem( 80 reason: string, 81 android: boolean, 82 overlayInstalled = false, 83 ): Message | null { 84 switch (reason) { 85 case 'no_player': 86 return { 87 text: android 88 ? 'No audiobook is playing. Start it in your player app.' 89 : 'Load the audio file first.', 90 }; 91 case 'no_overlay': 92 return overlayInstalled 93 ? { text: 'SubRead Overlay does not answer.', action: OPEN_OVERLAY } 94 : { 95 text: 'SubRead Overlay is not installed.', 96 link: { href: OVERLAY_RELEASES, text: 'Get it' }, 97 }; 98 case 'no_notification_access': 99 return { text: 'Allow notification access in SubRead Overlay.', action: OPEN_OVERLAY }; 100 default: 101 return null; 102 } 103 } 104 105 /** 106 * True when play and "audio to page" cannot work: no SubRead Overlay, no 107 * notification access, or on the web no audio file. 108 */ 109 export function playerCommandsOff(error: string | null, android: boolean): boolean { 110 return ( 111 error === 'no_overlay' || 112 error === 'no_notification_access' || 113 (!android && error === 'no_player') 114 ); 115 } 116 117 /** The text for a command that failed. A PlayerError gets the text of its reason. */ 118 export function errorMessage(err: unknown, android: boolean, overlayInstalled = false): Message { 119 if (err instanceof PlayerError) { 120 return ( 121 playerProblem(err.reason, android, overlayInstalled) ?? { 122 text: `The player failed: ${err.reason}.`, 123 } 124 ); 125 } 126 return { text: String(err) }; 127 } 128 129 /** 130 * True when `err` is a PlayerError for the problem of the last player state 131 * (`playerError`). The player part of the strip shows that problem, so the 132 * event part does not say it again. 133 */ 134 export function shownInPlayerPart(err: unknown, playerError: string | null | undefined): boolean { 135 return err instanceof PlayerError && err.reason === playerError; 136 } 137 138 /** The player part of the strip: the time and the play state, or the problem. */ 139 export function playerMessage( 140 s: ClockState | null, 141 android: boolean, 142 overlayInstalled = false, 143 ): Message { 144 const name = android ? 'Player' : 'Audio'; 145 if (!s) return { text: `${name}: not read yet.` }; 146 if (s.error !== null) { 147 return ( 148 playerProblem(s.error, android, overlayInstalled) ?? { 149 text: `${name}: cannot read it (${s.error}).`, 150 } 151 ); 152 } 153 if (s.positionMs === null) return { text: `${name}: no position.` }; 154 return { text: `${name} ${clockText(s.positionMs)}, ${s.playing ? 'playing' : 'paused'}` }; 155 } 156 157 export function pdfLoadedText(pages: number): string { 158 return `PDF loaded: ${count(pages, 'page')}.`; 159 } 160 161 /** The reading part of the strip while the pages are read. `note` tells about one page. */ 162 export function readingText(read: number, total: number, note: string): string { 163 return `Reading ${read}/${total}${note ? ` · ${note}` : ''}`; 164 } 165 166 /** The message after the last page is read. */ 167 export function pagesReadText(counts: { text: number; ocr: number }): string { 168 return `${count(counts.text + counts.ocr, 'page')} read: ${counts.text} text layer, ${counts.ocr} OCR.`; 169 } 170 171 export const OCR_START_TEXT = 172 'OCR could not start. The first OCR run needs a network connection to download the language data.'; 173 174 const RETRY = { id: 'retry-reading', text: 'Retry' } as const; 175 176 /** 177 * The reading part of the strip after the reading of the pages ends: what 178 * went wrong, with Retry. `failed` is the count of pages that could not be 179 * read. `startFailed` is true when OCR could not start. Null when each page 180 * is read. 181 */ 182 export function readingEndMessage(failed: number, startFailed: boolean): Message | null { 183 if (startFailed) return { text: OCR_START_TEXT, action: RETRY }; 184 if (failed > 0) return { text: `${count(failed, 'page')} could not be read.`, action: RETRY }; 185 return null; 186 } 187 188 export const FORCE_OCR_NOTE = 'Force OCR is on'; 189 190 /** The reading part of the strip, with the note while Force OCR is on for the open book. */ 191 export function withForceOcr(m: Message | null, forceOcr: boolean): Message | null { 192 if (!forceOcr) return m; 193 if (m === null) return { text: `${FORCE_OCR_NOTE}.` }; 194 const text = m.text.endsWith('.') 195 ? `${m.text} ${FORCE_OCR_NOTE}.` 196 : `${m.text} · ${FORCE_OCR_NOTE}`; 197 return { ...m, text }; 198 } 199 200 export const SUBREAD_NOT_INSTALLED: Message = { 201 text: 'SubRead is not installed.', 202 link: { href: SUBREAD_RELEASES, text: 'Get SubRead' }, 203 }; 204 205 /** SubRead waits for the reading: it makes the subtitles from the text of all pages. */ 206 export function waitPagesText(read: number, total: number): string { 207 return `Reading pages ${read}/${total} first...`; 208 } 209 210 /** 211 * The make status while a SubRead job waits for the pages of its book. 212 * `reading` is true while the reading of the pages runs. After the reading, 213 * the pages that are not read could not be read: the job waits until Retry 214 * reads them. Before, the status kept "Reading pages 38/40 first...", and 215 * SubRead never started. 216 */ 217 export function waitMessage(read: number, total: number, reading: boolean): Message { 218 if (reading) return { text: waitPagesText(read, total) }; 219 return { 220 text: `SubRead waits for ${count(total - read, 'page')} that could not be read.`, 221 action: RETRY, 222 }; 223 } 224 225 /** What SubRead made: the cues, the language and the share of the lines found in the book. */ 226 export function subreadResultText(r: { 227 cues?: number; 228 language?: string | null; 229 matchRate?: number; 230 }): string { 231 const cues = r.cues !== undefined && r.cues >= 0 ? String(r.cues) : '?'; 232 const rate = r.matchRate !== undefined && r.matchRate >= 0 ? r.matchRate : null; 233 return ( 234 `${cues} cues, language ${r.language ?? '?'}` + 235 (rate === null ? '.' : `, ${Math.round(rate * 100)}% of the lines found in the book.`) + 236 (rate !== null && rate < 0.8 237 ? ' Under 80% usually means another edition or the wrong language.' 238 : '') 239 ); 240 } 241 242 /** SubRead made the subtitles of the book `name`, which is not open. They wait for that book. */ 243 export function keptResultText(name: string): string { 244 return `SubRead made the subtitles of ${name}. The reader offers them when you open that book.`; 245 } 246 247 /** Why SubRead made no subtitles. `cannot_start` means that the read grant of the audio is gone. */ 248 export function subreadErrorText(error: string): string { 249 if (error.startsWith('cannot_start')) { 250 return 'SubRead cannot read the audio file. Choose the audio again.'; 251 } 252 return `SubRead made no subtitles: ${error}.`; 253 } 254 255 /** The question before the subtitles of SubRead load, with the reasons of subreadConcerns. */ 256 export function subreadQuestion(concerns: readonly string[]): string { 257 return ['SubRead made subtitles for this book.', ...concerns, 'Load them?'].join('\n\n'); 258 } 259 260 /** The question before the saved pages of one book go. */ 261 export function clearBookQuestion(name: string): string { 262 return `Remove the saved pages of ${name}? The reader reads them again the next time it opens the book.`; 263 } 264 265 export const CLEAR_ALL_QUESTION = 266 'Remove the saved pages of all books? The reader reads each book again the next time it opens it.'; 267 268 /** The message for a tap on a page that is not read yet. */ 269 export function notReadText(read: number, total: number): string { 270 return `This page is not read yet (${read}/${total} read).`; 271 } 272 273 /** The strip says this while the database upgrade holds the reading. */ 274 export const UPDATING_CACHE_TEXT = 'Updating the page cache...'; 275 276 /** The copy of the last book cannot be read, for example after the storage was cleared. */ 277 export const LAST_BOOK_GONE_TEXT = 'The last book is no longer in the app storage. Open it again.'; 278 279 /** A picked file of 0 bytes, for example a file that another app still writes. */ 280 export const EMPTY_FILE_TEXT = 'The file is empty or not ready. Try again in a moment.'; 281 282 /** The message when pdf.js cannot open a file. */ 283 export function pdfOpenText(err: unknown): string { 284 if (err instanceof Error && err.name === 'InvalidPDFException') { 285 return 'This file is not a PDF, or it is damaged.'; 286 } 287 return `Cannot open the PDF: ${String(err)}`; 288 } 289 290 /** The message for a subtitle file with no cues, for example a PDF. */ 291 export function noCuesText(name: string): string { 292 return `No subtitle lines in ${name}. Choose an .srt or .vtt file.`; 293 } 294 295 /** 296 * When all pages are read and less than this share of the cues is 297 * matched, the subtitles are for another text or another language. 298 */ 299 export const MISMATCH_SHARE = 0.3; 300 301 export const MISMATCH_TEXT = 'These subtitles do not match this book or the OCR language.'; 302 303 /** 304 * The count of matched cues. When all pages are read and less than 305 * MISMATCH_SHARE of the cues is matched, the hint follows. 306 */ 307 export function matchedText(matched: number, cues: number, allRead: boolean): string { 308 if (!allRead) return `${matched}/${cues} cues matched to the pages read so far.`; 309 const text = `${matched}/${cues} cues matched.`; 310 return cues > 0 && matched / cues < MISMATCH_SHARE ? `${text} ${MISMATCH_TEXT}` : text; 311 }