commit 84c266893998842e87bb1d0654abb82c373943de truex <truex@equwal.com> 2026-09-26 14:18:30 -0700 Fit the top bar to a phone, and show a start card At 360 CSS pixels the top bar was wider than the screen, and the page scrolled sideways. The bar now has small icon buttons with a label and a title on each: menu, previous page, the page label, next page, play or pause, and "move the audio to this page". With no PDF, the viewer shows a start card: the app name, what the app does, an "Open a PDF" button that opens the file field of the menu, a line about the subtitles, and an empty #suite-checklist for a later change. The arrows and the page label are disabled with no PDF, the previous arrow on the first page and the next arrow on the last page. The strip hides the player until a PDF is open, and an empty strip takes no space.
index.html | 58 ++++++++++++++++++++++++---- src/main.ts | 39 +++++++++++++++---- src/status.ts | 9 +++-- src/style.css | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 200 insertions(+), 26 deletions(-)
diff --git a/index.html b/index.html index 7fcc9df..79218ee 100644 --- a/index.html +++ b/index.html @@ -8,13 +8,42 @@ </head> <body> <header id="topbar"> - <button id="menu" aria-controls="panel" aria-expanded="false">Menu</button> - <button id="prev">Prev</button> - <span id="page-label">No PDF</span> - <button id="next">Next</button> - <button id="play" title="Play or pause the audiobook" disabled>▶</button> - <button id="sync-page" title="Move the audio to the first cue on this page" disabled> - Audio → page + <button + id="menu" + class="icon" + aria-controls="panel" + aria-expanded="false" + aria-label="Menu" + title="Menu" + > + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16" /></svg> + </button> + <button id="page-left" class="icon" aria-label="Previous page" title="Previous page" disabled> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" /></svg> + </button> + <button id="page-label" title="Go to a page" disabled>No PDF</button> + <button id="page-right" class="icon" aria-label="Next page" title="Next page" disabled> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7" /></svg> + </button> + <button id="play" class="icon" aria-label="Play" title="Play" disabled> + <svg class="i-play" viewBox="0 0 24 24" aria-hidden="true"> + <path class="solid" d="M8 5.5v13l10.5-6.5z" /> + </svg> + <svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true"> + <path class="solid" d="M6.5 5h4v14h-4zM13.5 5h4v14h-4z" /> + </svg> + </button> + <button + id="sync-page" + class="icon" + aria-label="Move the audio to this page" + title="Move the audio to this page" + disabled + > + <svg viewBox="0 0 24 24" aria-hidden="true"> + <path d="M6 3h8.5L19 7.5V21H6z" /> + <path class="solid" d="M10 10v7l5.5-3.5z" /> + </svg> </button> </header> @@ -99,7 +128,20 @@ <main id="main"> <div id="viewer"> - <div id="page"> + <section id="empty" class="card"> + <h2>Scan Subread</h2> + <p> + Read a PDF along with its audiobook. The page follows the narrator, and a tap on a word + looks it up. + </p> + <button id="open-pdf" class="primary">Open a PDF</button> + <p class="hint"> + Then open its subtitles (.srt or .vtt) in the menu, or make them with SubRead on + Android. + </p> + <div id="suite-checklist"></div> + </section> + <div id="page" hidden> <div id="overlay"></div> </div> </div> diff --git a/src/main.ts b/src/main.ts index 1bdd9e3..49eab49 100644 --- a/src/main.ts +++ b/src/main.ts @@ -75,12 +75,14 @@ const ui = { pauseLookup: el<HTMLInputElement>('pause-lookup'), clearCache: el<HTMLButtonElement>('clear-cache'), cues: el<HTMLOListElement>('cues'), - prev: el<HTMLButtonElement>('prev'), - next: el<HTMLButtonElement>('next'), + pageLeft: el<HTMLButtonElement>('page-left'), + pageRight: el<HTMLButtonElement>('page-right'), play: el<HTMLButtonElement>('play'), syncPage: el<HTMLButtonElement>('sync-page'), - pageLabel: el<HTMLSpanElement>('page-label'), + pageLabel: el<HTMLButtonElement>('page-label'), viewer: el<HTMLDivElement>('viewer'), + empty: el<HTMLElement>('empty'), + openPdf: el<HTMLButtonElement>('open-pdf'), page: el<HTMLDivElement>('page'), overlay: el<HTMLDivElement>('overlay'), }; @@ -186,6 +188,8 @@ document.addEventListener('click', (e) => { // --- The PDF --- +ui.openPdf.addEventListener('click', () => ui.pdfFile.click()); + ui.pdfFile.addEventListener('change', async () => { const file = ui.pdfFile.files?.[0]; if (!file) return; @@ -201,6 +205,9 @@ ui.pdfFile.addEventListener('change', async () => { state.activeCue = -1; state.markedCue = -1; ui.makeSubs.disabled = false; + ui.empty.hidden = true; + ui.page.hidden = false; + if (state.clock) showPlayerStatus(state.clock); say(pdfLoadedText(state.pdf.numPages)); await showPage(0); const saved = await getSrt(bookKey(file)); @@ -457,8 +464,14 @@ function markCueInList(i: number): void { function showPlayerStatus(s: ClockState): void { ui.play.disabled = s.error === 'no_overlay' || (!isAndroid && s.error === 'no_player'); - ui.play.textContent = s.playing ? '⏸' : '▶'; - showPlayer(playerMessage(s, isAndroid)); + ui.play.classList.toggle('playing', s.playing); + const action = s.playing ? 'Pause' : 'Play'; + if (ui.play.title !== action) { + ui.play.title = action; + ui.play.setAttribute('aria-label', action); + } + // Before a PDF is open, the start card tells what to do first. + showPlayer(state.pdf ? playerMessage(s, isAndroid) : null); } /** The cue at the position of a clock state, or -1. */ @@ -516,12 +529,22 @@ ui.audioFile.addEventListener('change', () => { // --- Page view --- +/** Shows the page label, and enables the arrows that lead to a page. */ +function updateNav(): void { + const pdf = state.pdf; + const page = state.currentPage; + ui.pageLabel.disabled = !pdf; + ui.pageLabel.textContent = pdf ? `${page + 1} / ${pdf.numPages}` : 'No PDF'; + ui.pageLeft.disabled = !pdf || page <= 0; + ui.pageRight.disabled = !pdf || page >= pdf.numPages - 1; +} + async function showPage(index: number): Promise<void> { const pdf = state.pdf; if (!pdf || index < 0 || index >= pdf.numPages) return; const seq = ++state.renderSeq; state.currentPage = index; - ui.pageLabel.textContent = `${index + 1} / ${pdf.numPages}`; + updateNav(); // Render at device resolution, up to the OCR width, so zoom stays sharp. const dpr = window.devicePixelRatio || 1; const width = Math.round(Math.min(OCR_WIDTH, Math.max(300, ui.viewer.clientWidth - 16) * dpr)); @@ -551,8 +574,8 @@ function drawBoxes(): void { } } -ui.prev.addEventListener('click', () => void showPage(state.currentPage - 1)); -ui.next.addEventListener('click', () => void showPage(state.currentPage + 1)); +ui.pageLeft.addEventListener('click', () => void showPage(state.currentPage - 1)); +ui.pageRight.addEventListener('click', () => void showPage(state.currentPage + 1)); // --- Tap on a word --- diff --git a/src/status.ts b/src/status.ts index 45d9b46..cb25cfb 100644 --- a/src/status.ts +++ b/src/status.ts @@ -37,14 +37,15 @@ function fill(target: HTMLElement, m: Message): void { let lastPlayer = ''; -/** Shows the state of the player. */ -export function showPlayer(m: Message): void { +/** Shows the state of the player. Null hides this part. */ +export function showPlayer(m: Message | null): void { // The clock sends a state four times a second. A new link under the // finger can lose a tap, so the part changes only when the text changes. - const key = `${m.text}|${m.link?.href ?? ''}`; + const key = m ? `${m.text}|${m.link?.href ?? ''}` : ''; if (key === lastPlayer) return; lastPlayer = key; - fill(player, m); + player.hidden = m === null; + fill(player, m ?? { text: '' }); } /** Shows how far the reading of the pages is. Null hides this part. */ diff --git a/src/style.css b/src/style.css index de7840a..8c4e291 100644 --- a/src/style.css +++ b/src/style.css @@ -22,22 +22,86 @@ body { overflow: hidden; } +/* The top bar fits a phone of 360 CSS pixels: small icon buttons, no text labels. */ #topbar { flex: none; box-sizing: border-box; height: var(--topbar); display: flex; - gap: 0.5rem; + gap: 2px; align-items: center; - padding: 0 0.5rem; + padding: 0 4px; border-bottom: 1px solid #8884; } -#page-label { - flex: 1; - text-align: center; - font-size: 0.85rem; +#topbar button { + flex: none; + box-sizing: border-box; + height: 2.25rem; + min-width: 2.25rem; + padding: 0 0.3rem; + display: inline-flex; + align-items: center; + justify-content: center; + font: inherit; + font-size: 0.9rem; white-space: nowrap; + color: inherit; + background: none; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; +} + +#topbar button:hover:not(:disabled) { + background: #8882; +} + +#topbar button:active:not(:disabled) { + background: #8884; +} + +#topbar button:focus-visible { + outline: 2px solid Highlight; + outline-offset: -2px; +} + +#topbar button:disabled { + opacity: 0.35; + cursor: default; +} + +#topbar svg { + width: 1.35rem; + height: 1.35rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +#topbar svg .solid { + fill: currentColor; + stroke: none; +} + +#play .i-pause, +#play.playing .i-play { + display: none; +} + +#play.playing .i-pause { + display: block; +} + +/* The page label takes the free space, and it opens the page jump. */ +#topbar #page-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; } /* The status strip: the player, the reading of the pages, the last event. */ @@ -60,6 +124,11 @@ body { min-width: 0; } +/* A strip with no text takes no space. */ +#strip:not(:has(> :not([hidden]):not(:empty))) { + display: none; +} + #strip a { color: inherit; } @@ -217,6 +286,45 @@ body.menu-open #panel { padding: 0.5rem; } +/* The start card: what the app does, and how to start. */ +.card { + box-sizing: border-box; + max-width: 28rem; + margin: 1.5rem auto; + padding: 1.25rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + border: 1px solid #8884; + border-radius: 10px; + line-height: 1.4; +} + +.card h2 { + margin: 0; + font-size: 1.3rem; +} + +.card p { + margin: 0; +} + +.card button.primary { + font: inherit; + font-weight: 600; + padding: 0.6rem 1.2rem; + border-radius: 8px; + border: none; + color: #1a1a1a; + background: rgb(255, 200, 0); + cursor: pointer; +} + +#suite-checklist:empty { + display: none; +} + #page { position: relative; margin: 0 auto;