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


commit 937174a6bf6bdf0d1b0dd7f7e02402233fbe01a8
truex <truex@equwal.com>
2026-09-26 23:44:56 -0700

Make each status message readable on a phone

The status strip is one line, so the page does not move. On the phone
a long event message was cut to "No subtitle li..." next to "Allow
notification acces..." and the "Open SubRead Overlay" button. The full
text was in the title attribute, and a phone cannot hover.

- While an event shows, it takes the place of the player text. The
  link or the button of the player stays.
- A tap on the strip, not on a link or a button in it, shows the full
  texts of the strip in a panel (#strip-full) over the top of the page.
  The panel has a fixed position, so the page does not move. The next
  tap closes it, and else it closes after 6 s, the time of an event.

stripTexts (messages.ts) gives the texts of the panel: the title of
each part that shows, else its text.

 README.md             | 10 +++++++++-
 index.html            |  2 ++
 src/messages.ts       | 23 ++++++++++++++++++++++
 src/status.ts         | 54 +++++++++++++++++++++++++++++++++++++++++++++++----
 src/style.css         | 52 +++++++++++++++++++++++++++++++++++++++++++++++--
 test/messages.test.ts | 27 ++++++++++++++++++++++++++
 6 files changed, 161 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 152e556..50fea5f 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,15 @@ The strip under the top bar shows three things:
   takes more than 2 minutes, the strip says so, with Retry. "Force OCR
   is on" shows while it is on for the book.
 - The last event, for example a lookup or an error. It fades after 6
-  seconds. Screen readers read this part.
+  seconds. While it shows, it takes the place of the player text, but
+  the button or the link of the player stays. Screen readers read this
+  part.
+
+The strip is one line, so the page under it does not move, and a long
+text ends with "…". Tap the strip to read the full texts: they show in a
+panel over the top of the page. The next tap closes the panel, and else
+it closes after 6 seconds. A tap on a button of the strip, for example
+Retry, only does what the button does.
 
 ### The three follow modes
 
diff --git a/index.html b/index.html
index 0f25ae5..63279eb 100644
--- a/index.html
+++ b/index.html
@@ -63,6 +63,8 @@
       <span id="pages-status" hidden></span>
       <span id="status" aria-live="polite"></span>
     </div>
+    <!-- The full texts of the strip after a tap on it. It floats over the page (status.ts). -->
+    <div id="strip-full" hidden></div>
 
     <aside id="panel">
       <h1>Scan Subread</h1>
diff --git a/src/messages.ts b/src/messages.ts
index 8440df3..fcb8023 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -12,6 +12,29 @@ export interface Message {
   action?: { id: ActionId; text: string };
 }
 
+/** A part of the status strip, for the panel of its full texts. */
+export interface StripPart {
+  /** False while the part is hidden. */
+  shown: boolean;
+  /** The full text of its message: its title attribute, or '' when it has none. */
+  title: string;
+  /** Its text as it shows, with the text of its link or its button. */
+  text: string;
+}
+
+/**
+ * The full texts of the parts of the strip that show, for the panel that a
+ * tap on the strip opens: the title of each part, else its text. A part
+ * with no text gives nothing. On the phone the one-line strip cut a long
+ * message, and a phone cannot hover to read the title.
+ */
+export function stripTexts(parts: readonly StripPart[]): string[] {
+  return parts
+    .filter((part) => part.shown)
+    .map((part) => part.title || part.text.trim())
+    .filter((text) => text !== '');
+}
+
 export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest';
 export const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest';
 export const DICTIONARY_RELEASES = 'https://github.com/equwal/subread-dictionary/releases/latest';
diff --git a/src/status.ts b/src/status.ts
index 0b6fc4f..3dff9ed 100644
--- a/src/status.ts
+++ b/src/status.ts
@@ -4,9 +4,11 @@
 //
 // The strip is one line of a fixed height, so the page under it never
 // moves. A long text ends with an ellipsis, and its title attribute holds
-// the full text.
+// the full text. A phone cannot hover to read the title, so a tap on the
+// strip shows the full texts in a panel over the top of the page. While an
+// event shows, it takes the place of the player text.
 
-import type { ActionId, Message } from './messages';
+import { stripTexts, type ActionId, type Message } from './messages';
 
 /** Milliseconds that an event message stays before it fades. */
 export const EVENT_MS = 6000;
@@ -20,11 +22,14 @@ function el(id: string): HTMLElement {
   return e;
 }
 
+const strip = el('strip');
 const player = el('player-status');
 const reading = el('pages-status');
 const event = el('status');
+const full = el('strip-full');
 
 let fadeTimer: ReturnType<typeof setTimeout> | undefined;
+let fullTimer: ReturnType<typeof setTimeout> | undefined;
 
 /**
  * Writes a message into `target`: the text in its own element, so that the
@@ -68,11 +73,44 @@ export function onAction(handler: (id: ActionId) => void): void {
   runAction = handler;
 }
 
+/**
+ * Shows the full texts of the parts of the strip in a panel over the top of
+ * the page, until the next tap or for EVENT_MS. The page does not move.
+ */
+function showFull(): void {
+  const parts = [...strip.children].filter((p): p is HTMLElement => p instanceof HTMLElement);
+  const texts = stripTexts(
+    parts.map((p) => ({ shown: !p.hidden, title: p.title, text: p.textContent ?? '' })),
+  );
+  if (texts.length === 0) return;
+  full.replaceChildren(
+    ...texts.map((text) => {
+      const line = document.createElement('p');
+      line.textContent = text;
+      return line;
+    }),
+  );
+  full.hidden = false;
+  clearTimeout(fullTimer);
+  fullTimer = setTimeout(hideFull, EVENT_MS);
+}
+
+function hideFull(): void {
+  clearTimeout(fullTimer);
+  full.hidden = true;
+}
+
+// A click on a button that fill made runs its action. The next tap closes
+// the panel of the full texts. A tap on the strip, not on a link or a
+// button in it, opens the panel.
 document.addEventListener('click', (e) => {
-  const button = e.target instanceof Element ? e.target.closest('button[data-action]') : null;
+  const target = e.target instanceof Element ? e.target : null;
+  const button = target?.closest('button[data-action]');
   if (button instanceof HTMLButtonElement && button.dataset.action) {
     runAction?.(button.dataset.action as ActionId);
   }
+  if (!full.hidden) hideFull();
+  else if (target && strip.contains(target) && !target.closest('a, button')) showFull();
 });
 
 let lastPlayer = '';
@@ -94,10 +132,17 @@ export function showReading(m: Message | string | null): void {
   fillPart(reading, typeof m === 'string' ? { text: m } : (m ?? { text: '' }));
 }
 
-/** Shows an event, for example a lookup or an error. It fades after EVENT_MS. */
+/**
+ * Shows an event, for example a lookup or an error. It fades after EVENT_MS.
+ * While it shows, it takes the place of the player text (style.css): on a
+ * phone the parts do not fit in one line. An open panel of the full texts
+ * shows the new event too.
+ */
 export function say(m: Message | string): void {
   fillPart(event, typeof m === 'string' ? { text: m } : m);
   event.classList.remove('faded');
+  strip.classList.add('event');
+  if (!full.hidden) showFull();
   clearTimeout(fadeTimer);
   fadeTimer = setTimeout(() => {
     event.classList.add('faded');
@@ -105,6 +150,7 @@ export function say(m: Message | string): void {
       event.replaceChildren();
       event.removeAttribute('title');
       event.classList.remove('faded');
+      strip.classList.remove('event');
     }, FADE_MS);
   }, EVENT_MS);
 }
diff --git a/src/style.css b/src/style.css
index 929339e..6d73129 100644
--- a/src/style.css
+++ b/src/style.css
@@ -5,6 +5,8 @@
   --highlight-solid: rgb(255, 200, 0);
   --highlight-weak: rgba(120, 160, 255, 0.35);
   --topbar: 3.25rem;
+  /* The height of the status strip. The panel of its full texts starts under it. */
+  --strip: 2rem;
   /* The size of a button of the top bar: 48 CSS px, the Android guideline for a touch target. */
   --target: 3rem;
   --panel-width: 22rem;
@@ -157,12 +159,14 @@ body {
  * One line of a fixed height, so the page under it never moves: a long
  * message wrapped and pushed the page down, and the page went up again
  * when the message faded. A long text ends with an ellipsis. Its title
- * attribute holds the full text (status.ts).
+ * attribute holds the full text, and a tap on the strip shows the full
+ * texts in #strip-full (status.ts).
  */
 #strip {
   flex: none;
   box-sizing: border-box;
-  height: 2rem;
+  height: var(--strip);
+  cursor: pointer;
   display: flex;
   flex-wrap: nowrap;
   align-items: stretch;
@@ -238,6 +242,46 @@ button.action {
   opacity: 0;
 }
 
+/*
+ * While an event shows, it takes the place of the player text: on a phone
+ * the parts do not fit in one line. The link or the button of the player
+ * stays.
+ */
+#strip.event #player-status .text,
+#strip.event #player-status:not(:has(a, button)) {
+  display: none;
+}
+
+/*
+ * The full texts of the strip, after a tap on it: a phone cannot hover to
+ * read the title attribute. The panel floats over the top of the page, so
+ * the page does not move. The next tap closes it.
+ */
+#strip-full {
+  position: fixed;
+  top: calc(var(--topbar) + var(--strip));
+  left: 0;
+  right: 0;
+  z-index: 6;
+  box-sizing: border-box;
+  max-height: 50vh;
+  overflow-y: auto;
+  padding: 0.5rem 0.75rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+  font-size: 0.85rem;
+  line-height: 1.35;
+  background: Canvas;
+  border-bottom: 1px solid #8884;
+  box-shadow: 0 4px 12px #0004;
+}
+
+#strip-full p {
+  margin: 0;
+  overflow-wrap: anywhere;
+}
+
 #panel {
   position: fixed;
   top: var(--topbar);
@@ -525,6 +569,10 @@ body.zoomed #viewer {
     min-width: 0;
   }
 
+  #strip-full {
+    left: var(--panel-width);
+  }
+
   #panel {
     grid-column: 1;
     grid-row: 1 / 4;
diff --git a/test/messages.test.ts b/test/messages.test.ts
index 44f0762..24bb3eb 100644
--- a/test/messages.test.ts
+++ b/test/messages.test.ts
@@ -24,6 +24,7 @@ import {
   readingText,
   keptResultText,
   shownInPlayerPart,
+  stripTexts,
   subreadErrorText,
   subreadQuestion,
   subreadResultText,
@@ -32,6 +33,32 @@ import {
   withForceOcr,
 } from '../src/messages';
 
+describe('stripTexts', () => {
+  it('gives the full text of each part that shows, for the panel of the strip', () => {
+    // The phone finding: the strip showed "No subtitle li..." next to
+    // "Allow notification acces..." and the "Open SubRead Overlay" button.
+    const player = 'Allow notification access in SubRead Overlay.';
+    const event = 'No subtitle lines in book.srt.pdf. Choose an .srt or .vtt file.';
+    expect(
+      stripTexts([
+        { shown: true, title: player, text: `${player} Open SubRead Overlay` },
+        { shown: false, title: '', text: 'Follow paused' },
+        { shown: false, title: '', text: '' },
+        { shown: true, title: event, text: event },
+      ]),
+    ).toEqual([player, event]);
+  });
+
+  it('takes the text of a part without a title, and skips a part without text', () => {
+    expect(
+      stripTexts([
+        { shown: true, title: '', text: 'Follow paused' },
+        { shown: true, title: '', text: ' ' },
+      ]),
+    ).toEqual(['Follow paused']);
+  });
+});
+
 describe('waitMessage', () => {
   const retry = { id: 'retry-reading', text: 'Retry' };