diff --git a/src/css/_controls.scss b/src/css/_controls.scss index f152b9431..afa50c139 100644 --- a/src/css/_controls.scss +++ b/src/css/_controls.scss @@ -30,29 +30,37 @@ } select.playback-speed, select.playback-voices { - padding-top: 18px; + height: 24px; + padding: 0 6px 0 26px; -moz-appearance: none; -webkit-appearance: none; appearance: none; - font-size: 10px; - text-align-last: center; + font-size: 12px; color: $controlsText; border: none; + border-radius: 4px; cursor: pointer; + background: transparent no-repeat 4px 50% / 18px; + &:hover { + background-color: rgba($white, 0.1); + } option, optgroup { background: $controlsBG; } } select.playback-speed { - width: 30px; - background: transparent url("icons/playback-speed.svg") 50% 0 no-repeat; + background-image: url("icons/playback-speed.svg"); + // the gauge only fills the top half of the icon's canvas + background-position-y: 6px; } select.playback-voices { - width: 30px; - background: transparent url("icons/voice.svg") 50% 0 no-repeat; - background-size: 18px; + max-width: 200px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + background-image: url("icons/voice.svg"); } .active { @@ -99,16 +107,24 @@ .read-aloud { display: none; position: absolute; - bottom: 100%; + bottom: calc(100% + 12px); + // left/right 0 + auto margins centers the max-content width without a + // transform, which the slideUp animation already owns right: 0; left: 0; z-index: 1; - padding: 5px 0; - margin: 0; - align-items: center; - justify-content: center; - list-style: none; - background: $controlsBG; + width: max-content; + max-width: min(420px, calc(100% - 24px)); + padding: 6px 8px; + margin: 0 auto; + flex-direction: column; + align-items: stretch; + background: rgba($controlsBG, 0.75); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba($white, 0.12); + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); &.visible { display: flex; @@ -119,8 +135,243 @@ } } + // Specific enough to beat `.visible button` + button.read-aloud-size-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: $controlsText; + border: none; + border-radius: 50%; + background: transparent; + cursor: pointer; + opacity: 0.7; + + &:hover, &:focus-visible { + opacity: 1; + background: rgba($white, 0.1); + } + + svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; + } + + .icon-expand { + display: none; + } + } + + // Far right of the playback row; see the .read-aloud-controls grid + li.read-aloud-size { + grid-column: 5; + justify-self: end; + padding: 0; + } + + &.minimized { + .read-aloud-chapter, .read-aloud-settings { + display: none; + } + + // Floats just outside the bar's right edge instead + li.read-aloud-size { + position: absolute; + top: 50%; + left: 100%; + margin-left: 6px; + padding: 0; + transform: translateY(-50%); + + button.read-aloud-size-button { + width: 32px; + height: 32px; + opacity: 1; + background: rgba($controlsBG, 0.75); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba($white, 0.12); + border-radius: 50%; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); + + &:hover, &:focus-visible { + background: rgba($controlsBG, 0.9); + } + + .icon-minimize { + display: none; + } + + .icon-expand { + display: block; + } + } + } + + // Without the toggle there's nothing to balance + .read-aloud-controls { + grid-template-columns: repeat(3, auto); + + > :first-child { + grid-column-start: auto; + } + } + } + + &.maximized { + top: auto; + bottom: 100%; + z-index: 3; + width: auto; + max-width: none; + box-sizing: border-box; + height: var(--read-aloud-maximized-height, 100vh); + padding: 32px 0 24px; + margin: 0; + // controls sit at the bottom; the cover takes up the space above them + justify-content: flex-end; + align-items: center; + background: $controlsBG; + border: none; + border-radius: 0; + box-shadow: none; + + > * { + box-sizing: border-box; + width: min(480px, calc(100% - 48px)); + } + + + .read-aloud-chapter { + padding: 0 0 16px; + } + + button.read-aloud-chapter-name { + width: 100%; + margin: 0 0 12px; + padding: 4px 8px; + border-radius: 4px; + } + + .read-aloud-chapter-text { + white-space: normal; + } + + + .read-aloud-settings { + margin-top: 16px; + padding-top: 16px; + } + + .read-aloud-cover:not(.failed) { + display: block; + flex: 1 1 auto; + min-height: 0; + margin-bottom: 24px; + object-fit: contain; + } + + } + + .read-aloud-cover { + display: none; + } + + .read-aloud-chapter { + min-width: 0; + padding: 2px 8px 4px; + + &.empty { + display: none; + } + } + + // Specific enough to beat `.visible button` + button.read-aloud-chapter-name { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + // Bleed over the toolbar's and row's padding so the whole top strip of + // the toolbar is clickable + width: calc(100% + 32px); + margin: -8px -16px 0; + padding: 8px 16px 4px; + font-size: 12px; + line-height: 1.3; + color: $controlsText; + background: transparent; + border: none; + border-radius: 8px 8px 0 0; + cursor: pointer; + + &:hover, &:focus-visible { + background: rgba($white, 0.1); + } + + ia-icon-toc { + flex-shrink: 0; + width: 14px; + height: 14px; + --iconFillColor: #{$controlsText}; + } + } + + .read-aloud-chapter-text { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + + br-tts-chapter-progress { + margin-top: 4px; + } + + .read-aloud-chapter-label { + opacity: 0.65; + &:not(:empty) + .read-aloud-chapter-title:not(:empty)::before { + content: "· "; + opacity: 0.65; + } + } + + .read-aloud-controls, .read-aloud-settings { + display: flex; + padding: 0; + margin: 0; + align-items: center; + justify-content: center; + list-style: none; + } + + // The empty 1fr column mirrors the size toggle's, keeping play centered + .read-aloud-controls { + display: grid; + grid-template-columns: 1fr repeat(3, auto) 1fr; + + > :first-child { + grid-column-start: 2; + } + } + + .read-aloud-settings { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid rgba($white, 0.12); + } + li { - padding: 0 10px; + padding: 0 8px; } [name="play"] { diff --git a/src/ia-bookreader/ia-bookreader.js b/src/ia-bookreader/ia-bookreader.js index 233572eeb..0201da223 100644 --- a/src/ia-bookreader/ia-bookreader.js +++ b/src/ia-bookreader/ia-bookreader.js @@ -365,6 +365,17 @@ export class IaBookReader extends LitElement { return this._brWidth >= 640; } + /** + * Panels fire `closeMenu` after the user picks something (e.g. a search + * result or chapter). Only close on narrow screens, where the menu covers + * the book. + */ + closeMenuIfNarrow() { + if (!this.isWideEnoughToOpenMenu) { + this.itemNav?.closeMenu(); + } + } + /** * Open side menu * @param {string} menuId @@ -585,6 +596,7 @@ export class IaBookReader extends LitElement { .menuShortcuts=${this.menuShortcuts} .menuContents=${this.menuContents} .openMenu=${this.openMenuName} + @closeMenu=${this.closeMenuIfNarrow} >
diff --git a/src/plugins/plugin.chapters.js b/src/plugins/plugin.chapters.js index e43ebb971..d1f545080 100644 --- a/src/plugins/plugin.chapters.js +++ b/src/plugins/plugin.chapters.js @@ -120,10 +120,7 @@ export class ChaptersPlugin extends BookReaderPlugin { label: 'Table of Contents', component: html` $(event.target).removeClass('front')) - .on('click', () => { - this._updateCurrent(tocEntry.pageIndex); - this.br.jumpToIndex(tocEntry.pageIndex, { ariaLive: true }); - }); + .on('click', () => this._jumpToChapter(tocEntry.pageIndex)); this.br.$('.BRchapter, .BRsearch').each((i, el) => { const $el = $(el); @@ -187,11 +181,35 @@ export class ChaptersPlugin extends BookReaderPlugin { }); } + /** + * Fires `chapterSelected` so e.g. read aloud can follow along + * @param {PageIndex} pageIndex + */ + _jumpToChapter(pageIndex) { + if (pageIndex == undefined) return; + this._updateCurrent(pageIndex); + this.br.jumpToIndex(pageIndex, { ariaLive: true }); + this.br.trigger('chapterSelected', { pageIndex }); + } + + /** @type {Promise | null} */ + _openLibraryRecord = null; + + /** + * The book's Open Library edition record. Fetched once and shared, since + * other plugins (e.g. tts, for the cover) use it too. + * @returns {Promise} + */ + getOpenLibraryRecord() { + this._openLibraryRecord ||= this._fetchOpenLibraryRecord(); + return this._openLibraryRecord; + } + /** * This makes a call to OL API and calls the given callback function with the * response from the API. */ - async getOpenLibraryRecord() { + async _fetchOpenLibraryRecord() { const olHost = applyVariables(this.options.openLibraryHost, this.br.options.vars); if (this.options.openLibraryId) { @@ -230,15 +248,31 @@ export class ChaptersPlugin extends BookReaderPlugin { _updateCurrent( curIndex = (this.br.mode == 2 ? Math.max(...this.br.displayedIndices) : this.br.firstIndex), ) { - const tocEntriesIndexed = this._tocEntries.filter((el) => el.pageIndex != undefined).reverse(); - const currChapter = tocEntriesIndexed[ - // subtract one so that 2up shows the right label - tocEntriesIndexed.findIndex((chapter) => chapter.pageIndex <= curIndex) - ]; if (this._chaptersPanel) { - this._chaptersPanel.currentChapter = currChapter; + this._chaptersPanel.currentChapter = this.getChapterAt(curIndex); } } + + /** + * The chapter containing the given page, if the table of contents has loaded + * @param {PageIndex} pageIndex + * @returns {TocEntry | undefined} + */ + getChapterAt(pageIndex) { + return this._tocEntries + ?.filter((el) => el.pageIndex != undefined) + .findLast((chapter) => chapter.pageIndex <= pageIndex); + } + + /** + * First page index after the chapter (the page count for the last chapter) + * @param {TocEntry} chapter + * @returns {PageIndex} + */ + getChapterEnd(chapter) { + return this._tocEntries.find((el) => el.pageIndex > chapter.pageIndex)?.pageIndex + ?? this.br.book.getNumLeafs(); + } } BookReader?.registerPlugin('chapters', ChaptersPlugin); @@ -289,6 +323,16 @@ export class BRChaptersPanel extends LitElement { `; } + /** + * @param {TocEntry} tocEntry + */ + selectEntry(tocEntry) { + if (tocEntry.pageIndex == undefined) return; + this.jumpToPage(tocEntry.pageIndex); + // Lets the shell close the side menu on narrow screens + this.dispatchEvent(new CustomEvent('closeMenu', { bubbles: true, composed: true })); + } + /** * @param {TocEntry} tocEntry */ @@ -307,7 +351,7 @@ export class BRChaptersPanel extends LitElement { " style="${styleMap({marginLeft: (tocEntry.level - 1) * 10 + 'px'})}" data-event-click-tracking="${ifDefined(clickable ? "BRTOCPanel|GoToChapter" : undefined)}" - @click="${() => this.jumpToPage(tocEntry.pageIndex)}" + @click="${() => this.selectEntry(tocEntry)}" > ${chapterTitle} ${tocEntry.pagenum ? html` diff --git a/src/plugins/tts/AbstractTTSEngine.js b/src/plugins/tts/AbstractTTSEngine.js index 81ec0a516..19dffb656 100644 --- a/src/plugins/tts/AbstractTTSEngine.js +++ b/src/plugins/tts/AbstractTTSEngine.js @@ -137,6 +137,34 @@ export default class AbstractTTSEngine { this.step(); } + /** + * @public + * @param {PageChunk} chunk + * @return {{ index: number, count: number } | null} + */ + getPagePosition(chunk) { + return this._chunkIterator?.getPagePosition(chunk) ?? null; + } + + /** + * @public + * @param {number} leafIndex + * @param {number} [pageFraction] 0-1, how far into the page to start + * @param {boolean} [play] resume playback if paused + */ + async jumpTo(leafIndex, pageFraction = 0, play = false) { + if (!this._chunkIterator) return; + await Promise.all([ + this.activeSound?.stop(), + this._chunkIterator.seek(leafIndex, pageFraction), + ]); + if (play && this.paused) { + this.paused = false; + this.events.trigger('resume'); + } + this.step(); + } + /** @param {string} voiceURI */ setVoice(voiceURI) { // if the user actively selects a voice, don't re-choose best voice anymore diff --git a/src/plugins/tts/PageChunkIterator.js b/src/plugins/tts/PageChunkIterator.js index ce0e21848..1d9d4e1e7 100644 --- a/src/plugins/tts/PageChunkIterator.js +++ b/src/plugins/tts/PageChunkIterator.js @@ -44,6 +44,35 @@ export default class PageChunkIterator { .then(() => this._decrementUncontrolled()); } + /** + * Moves the cursor to the chunk at the given fraction of a page + * @param {number} page + * @param {number} [pageFraction] 0-1 + * @return {Promise} + */ + seek(page, pageFraction = 0) { + return this._cursorLock = this._cursorLock + .then(async () => { + this._recenterBuffer(page); + const chunks = await this._fetchPageChunks(page); + this._cursor = { + page, + chunk: Math.max(0, Math.min(Math.floor(pageFraction * chunks.length), chunks.length - 1)), + }; + }); + } + + /** + * Where a chunk sits on its page, if that page is still buffered + * @param {PageChunk} chunk + * @return {{ index: number, count: number } | null} + */ + getPagePosition(chunk) { + const chunks = this._bufferedPages[chunk.leafIndex]; + const index = chunks?.indexOf(chunk) ?? -1; + return index == -1 ? null : { index, count: chunks.length }; + } + /** * Gets without ensuring synchronization. Since this iterator has a lot of async * code, calling e.g. "next" twice (before the first call to next has finished) diff --git a/src/plugins/tts/chapter-progress.js b/src/plugins/tts/chapter-progress.js new file mode 100644 index 000000000..bee793458 --- /dev/null +++ b/src/plugins/tts/chapter-progress.js @@ -0,0 +1,141 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +/** + * Slider showing how far read aloud is through the current chapter. + * Fires `seek` with `detail.value` (0-1) when the user releases the thumb. + */ +@customElement('br-tts-chapter-progress') +export class BRTtsChapterProgress extends LitElement { + /** Fraction (0-1) of the chapter that has been read */ + @property({ type: Number }) + value = 0; + + /** Number of pages in the chapter; the track shows a divider between pages */ + @property({ type: Number }) + pages = 1; + + @property({ type: String }) + label = 'Chapter progress'; + + /** + * Position while the user is dragging; takes precedence over `value` so + * playback updates don't yank the thumb out from under them + * @type {number | null} + */ + @state() + _dragValue = null; + + /** @param {InputEvent & { target: HTMLInputElement }} ev */ + _onInput(ev) { + this._dragValue = parseFloat(ev.target.value); + } + + /** @param {Event & { target: HTMLInputElement }} ev */ + _onChange(ev) { + const value = parseFloat(ev.target.value); + this._dragValue = null; + this.value = value; + this.dispatchEvent(new CustomEvent('seek', { detail: { value } })); + } + + render() { + const value = this._dragValue ?? this.value; + return html` + + `; + } + + static styles = css` + :host { + display: block; + /* Dark track, so it stands out from the toolbar's grey */ + --track-color: #1c1c1c; + --fill-color: #dcdcdc; + --thumb-color: #fff; + /* Roughly the toolbar's grey, so dividers read as gaps on both the + light (read) and dark (unread) parts */ + --page-divider-color: rgba(68, 68, 68, 0.5); + } + + input { + display: block; + width: 100%; + height: 14px; + margin: 0; + background: transparent; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + } + + input:focus { + outline: none; + } + + /* A soft line at the end of each page, over the read/unread fill */ + input::-webkit-slider-runnable-track { + height: 3px; + border-radius: 1.5px; + background: + repeating-linear-gradient(to right, + transparent 0 calc(var(--page-width) - 3px), + var(--page-divider-color) calc(var(--page-width) - 1.5px), + transparent var(--page-width)), + linear-gradient(to right, var(--fill-color) var(--progress), var(--track-color) var(--progress)); + } + + /* Same as webkit; -moz-range-progress would only span the read part, misaligning the dividers */ + input::-moz-range-track { + height: 3px; + border-radius: 1.5px; + background: + repeating-linear-gradient(to right, + transparent 0 calc(var(--page-width) - 3px), + var(--page-divider-color) calc(var(--page-width) - 1.5px), + transparent var(--page-width)), + linear-gradient(to right, var(--fill-color) var(--progress), var(--track-color) var(--progress)); + } + + input::-webkit-slider-thumb { + width: 10px; + height: 10px; + /* (track height - thumb height) / 2 */ + margin-top: -3.5px; + border: 0; + border-radius: 50%; + background: var(--thumb-color); + -webkit-appearance: none; + transition: transform 0.1s; + } + + input::-moz-range-thumb { + width: 10px; + height: 10px; + border: 0; + border-radius: 50%; + background: var(--thumb-color); + transition: transform 0.1s; + } + + input:hover::-webkit-slider-thumb, + input:focus-visible::-webkit-slider-thumb { + transform: scale(1.3); + } + + input:hover::-moz-range-thumb, + input:focus-visible::-moz-range-thumb { + transform: scale(1.3); + } + `; +} diff --git a/src/plugins/tts/plugin.tts.js b/src/plugins/tts/plugin.tts.js index a5d4433ff..964d61005 100644 --- a/src/plugins/tts/plugin.tts.js +++ b/src/plugins/tts/plugin.tts.js @@ -3,6 +3,7 @@ import FestivalTTSEngine from './FestivalTTSEngine.js'; import WebTTSEngine from './WebTTSEngine.js'; import { toISO6391 } from './utils.js'; +import './chapter-progress.js'; import { en as tooltips } from './tooltip_dict.js'; import { renderBoxesInPageContainerLayer } from '../../BookReader/PageContainer.js'; import { BookReaderPlugin } from '../../BookReaderPlugin.js'; @@ -18,6 +19,13 @@ const BookReader = /** @type {typeof import('../../BookReader').default} */(wind export class TtsPlugin extends BookReaderPlugin { options = { enabled: true, + /** + * @type {import('@/src/util/strings.js').StringWithVars} + * Cover for the maximized player, used when the chapters plugin finds the + * book's Open Library edition. Provided variables: + * - `coverId` -- the edition's Open Library cover ID + */ + openLibraryCoverUrl: 'https://covers.openlibrary.org/b/id/{{coverId}}-L.jpg', /** * @type {import('@/src/util/strings.js').StringWithVars} * The URL where to get PageChunk objects for a given page. Expects a var `pageIndex` @@ -118,40 +126,61 @@ export class TtsPlugin extends BookReaderPlugin { if (!this.options.enabled || !this.ttsEngine) return; this.br.refs.$BRReadAloudToolbar = $(` -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
+ + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
    +
  • + +
  • +
  • + +
  • +
+ `); $navBar.find('.BRcontrols').prepend(this.br.refs.$BRReadAloudToolbar); @@ -187,9 +216,9 @@ export class TtsPlugin extends BookReaderPlugin { } voicesMenu.val(this.ttsEngine.voice.voiceURI); - voicesMenu.show(); + voicesMenu.closest('li').show(); } else { - voicesMenu.hide(); + voicesMenu.closest('li').hide(); } }; @@ -203,6 +232,15 @@ export class TtsPlugin extends BookReaderPlugin { this.br.refs.$BRReadAloudToolbar.find('[name=play]').on("click", this.playPause.bind(this)); this.br.refs.$BRReadAloudToolbar.find('[name=advance]').on("click", this.jumpForward.bind(this)); this.br.refs.$BRReadAloudToolbar.find('[name=review]').on("click", this.jumpBackward.bind(this)); + this.br.refs.$BRReadAloudToolbar.find('[name=toggle-size]') + .on('click', () => this.setToolbarMode(this.toolbarMode == 'minimized' ? this._expandedMode : 'minimized')); + this.br.on(BookReader.eventNames.resize, () => { + if (this.ttsEngine.playing && this.toolbarMode != 'minimized') this.setToolbarMode(this._expandedMode); + }); + this.br.refs.$BRReadAloudToolbar.find('[name=chapters]').on('click', () => this.toggleChaptersPanel()); + this.br.on('chapterSelected', (_ev, { pageIndex }) => this.onChapterSelected(pageIndex)); + this.br.refs.$BRReadAloudToolbar.find('br-tts-chapter-progress') + .on('seek', (ev) => this.seekInChapter(ev.originalEvent.detail.value)); const $rateSelector = this.br.refs.$BRReadAloudToolbar.find('select[name="playback-speed"]'); $rateSelector.on("change", ev => this.ttsEngine.setPlaybackRate(parseFloat($rateSelector.val()))); $(`
  • @@ -232,6 +270,9 @@ export class TtsPlugin extends BookReaderPlugin { if (this.br.constModeThumb == this.br.mode) this.br.switchMode(this.br.constMode1up); + this.updateChapter(this.br.currentIndex()); + // On mobile the maximized player would hide the book, so start minimized + this.setToolbarMode(this._expandedMode == 'maximized' ? 'minimized' : 'normal'); this.br.refs.$BRReadAloudToolbar.addClass('visible'); this.br.$('.BRicon.read').addClass('unread active'); this.sendAnalyticsEvent('Start'); @@ -283,10 +324,159 @@ export class TtsPlugin extends BookReaderPlugin { */ async beforeChunkPlay(chunk) { await this.maybeFlipToIndex(chunk.leafIndex); + this.updateChapter(chunk.leafIndex, chunk); this.highlightChunk(chunk); this.scrollToChunk(chunk); } + /** + * Show the chapter being read, and how far through it we are, in the + * toolbar; the row hides if there's no TOC + * @param {number} leafIndex + * @param {PageChunk} [chunk] the chunk about to be read on that page + */ + updateChapter(leafIndex, chunk) { + const chapters = this.br.plugins.chapters; + const chapter = chapters?.getChapterAt(leafIndex); + const $chapter = this.br.refs.$BRReadAloudToolbar.find('.read-aloud-chapter'); + $chapter.toggleClass('empty', !chapter); + if (!chapter) return; + + $chapter.find('.read-aloud-chapter-label').text(chapter.label?.trim() || ''); + $chapter.find('.read-aloud-chapter-title').text(chapter.title?.trim() || ''); + + // Each page gets an equal share of the chapter, split evenly between its chunks + const start = chapter.pageIndex; + const end = chapters.getChapterEnd(chapter); + const pagePosition = chunk && this.ttsEngine.getPagePosition(chunk); + const pageFraction = pagePosition ? pagePosition.index / pagePosition.count : 0; + const progress = (leafIndex - start + pageFraction) / Math.max(1, end - start); + /** @type {import('./chapter-progress.js').BRTtsChapterProgress} */ + const $progress = $chapter.find('br-tts-chapter-progress')[0]; + $progress.value = Math.min(1, Math.max(0, progress)); + $progress.pages = end - start; + $progress.label = `Progress through ${chapter.title || chapter.label || 'chapter'}`; + } + + /** @type {'normal' | 'minimized' | 'maximized'} */ + toolbarMode = 'normal'; + + /** + * The toolbar only toggles between minimized and one expanded mode: the + * floating bar on desktop, or the maximized player on mobile, where the + * floating bar would cover most of the page anyway + * @type {'normal' | 'maximized'} + */ + get _expandedMode() { + // Same phone cutoff as the navbar and the side menu + return this.br.refs.$br.width() < 640 ? 'maximized' : 'normal'; + } + + /** @type {ResizeObserver | null} */ + _maximizedResizeObserver = null; + + /** + * Minimized shows just the playback buttons; maximized covers the reader + * above the footer, hiding the book + * @param {'normal' | 'minimized' | 'maximized'} mode + */ + setToolbarMode(mode) { + this.toolbarMode = mode; + const $toolbar = this.br.refs.$BRReadAloudToolbar; + $toolbar + .toggleClass('minimized', mode == 'minimized') + .toggleClass('maximized', mode == 'maximized'); + $toolbar.find('[name=toggle-size]') + .attr('title', mode == 'minimized' ? tooltips.expand : tooltips.minimize); + + // The toolbar is positioned within the footer, so it can't size itself to + // the space above the footer with CSS alone + this._maximizedResizeObserver?.disconnect(); + this._maximizedResizeObserver = null; + if (mode == 'maximized') { + this._loadCover(); + const footer = $toolbar.closest('.BRfooter')[0]; + this._maximizedResizeObserver = new ResizeObserver(() => { + $toolbar[0].style.setProperty('--read-aloud-maximized-height', `${footer.offsetTop}px`); + }); + this._maximizedResizeObserver.observe(this.br.refs.$br[0]); + this._maximizedResizeObserver.observe(footer); + } + } + + /** @type {Promise | null} */ + _coverLoading = null; + + /** + * Shown in the maximized player. Prefers the Open Library edition's cover, + * then the book's thumbnail, then the first page, which is the cover for + * most scanned books. + */ + _loadCover() { + this._coverLoading ||= this._getCoverUrl().then(src => { + const $cover = this.br.refs.$BRReadAloudToolbar.find('.read-aloud-cover'); + $cover + .one('error', () => $cover.addClass('failed')) + .attr('src', src); + }); + } + + /** @returns {Promise} */ + async _getCoverUrl() { + const olEdition = await this.br.plugins.chapters?.getOpenLibraryRecord().catch(() => null); + const coverId = olEdition?.covers?.find(id => id > 0); + if (coverId) { + return applyVariables(this.options.openLibraryCoverUrl, this.br.options.vars, { coverId }); + } + + let src = this.br.thumbnail; + if (!src) { + // Smallest power-of-2 reduction that's still sharp at ~800px tall + const height = this.br.book.getPageHeight(0); + const reduce = 2 ** Math.max(0, Math.floor(Math.log2(height / 800))); + src = this.br.book.getPageURI(0, reduce, 0); + } + return src; + } + + /** Open the table of contents side panel, or close it if it's already showing */ + toggleChaptersPanel() { + const itemNav = this.br.shell?.itemNav; + if (itemNav?.menuOpened && itemNav.openMenu == 'chapters') { + itemNav.toggleMenu(false); + } else { + this.br.shell?.updateSideMenu('chapters', 'open'); + } + } + + /** + * Restart read aloud at a chapter picked from the table of contents + * @param {number} pageIndex + */ + onChapterSelected(pageIndex) { + if (!this.ttsEngine?.playing) return; + this.updateChapter(pageIndex); + // Stay paused if paused; resuming then continues from the new chapter + this.ttsEngine.jumpTo(pageIndex, 0, !this.ttsEngine.paused); + } + + /** + * Jump read aloud to a point in the current chapter + * @param {number} fraction 0-1 + */ + seekInChapter(fraction) { + const chapters = this.br.plugins.chapters; + const chunk = this.ttsEngine.activeSound?.chunk; + const chapter = chapters?.getChapterAt(chunk?.leafIndex ?? this.br.currentIndex()); + if (!chapter || !this.ttsEngine.playing) return; + + const start = chapter.pageIndex; + const end = chapters.getChapterEnd(chapter); + const position = start + fraction * (end - start); + const leafIndex = Math.min(Math.floor(position), end - 1); + this.ttsEngine.jumpTo(leafIndex, position - leafIndex); + } + /** * @param {PageChunk} chunk */ diff --git a/src/plugins/tts/tooltip_dict.js b/src/plugins/tts/tooltip_dict.js index e656f7db2..a0bae86b6 100644 --- a/src/plugins/tts/tooltip_dict.js +++ b/src/plugins/tts/tooltip_dict.js @@ -1,5 +1,8 @@ export const en = { advance: 'Advance 10 seconds', + chapters: 'Toggle table of contents', + expand: 'Show all read aloud controls', + minimize: 'Minimize read aloud controls', play: 'Play', playbackSpeed: 'Playback speed', readAloud: 'Read this book aloud', @@ -8,6 +11,9 @@ export const en = { export const es = { advance: 'Avance 10 segundos', + chapters: 'Mostrar u ocultar el índice', + expand: 'Mostrar todos los controles de lectura en voz alta', + minimize: 'Minimizar los controles de lectura en voz alta', play: 'Jugar', playbackSpeed: 'Velocidad de reproducción', readAloud: 'Lee este libro en voz alta',