diff --git a/src/components/actionSheet/actionSheet.ts b/src/components/actionSheet/actionSheet.ts index 54bf0804df0..4ef79e704da 100644 --- a/src/components/actionSheet/actionSheet.ts +++ b/src/components/actionSheet/actionSheet.ts @@ -86,7 +86,7 @@ function getOffsets(elems: Element[]): Offset[] { return results; } -function getPosition(positionTo: Element, options: Options, dlg: HTMLElement) { +export function getPosition(positionTo: Element, options: Options, dlg: HTMLElement) { const windowSize = dom.getWindowSize(); const windowHeight = windowSize.innerHeight; const windowWidth = windowSize.innerWidth; @@ -387,5 +387,6 @@ export function show(options: Options) { } export default { - show: show + show, + getPosition, }; diff --git a/src/plugins/bookPlayer/plugin.js b/src/plugins/bookPlayer/plugin.js index 733519cb389..e0d786a719a 100644 --- a/src/plugins/bookPlayer/plugin.js +++ b/src/plugins/bookPlayer/plugin.js @@ -5,58 +5,251 @@ import keyboardnavigation from '../../scripts/keyboardNavigation'; import dialogHelper from '../../components/dialogHelper/dialogHelper'; import ServerConnections from '../../components/ServerConnections'; import Screenfull from 'screenfull'; -import TableOfContents from './tableOfContents'; import { translateHtml } from '../../lib/globalize'; import browser from 'scripts/browser'; -import * as userSettings from '../../scripts/settings/userSettings'; +import { currentSettings as userSettings } from '../../scripts/settings/userSettings'; import TouchHelper from 'scripts/touchHelper'; import { PluginType } from '../../types/plugin.ts'; import Events from '../../utils/events.ts'; +import globalize from '../../lib/globalize'; +import * as EpubJS from 'epubjs'; +import actionSheet from '../../components/actionSheet/actionSheet'; import '../../elements/emby-button/paper-icon-button-light'; -import html from './template.html'; -import './style.scss'; - -const THEMES = { - 'dark': { 'body': { 'color': '#d8dadc', 'background': '#000', 'font-size': 'medium' } }, - 'sepia': { 'body': { 'color': '#d8a262', 'background': '#000', 'font-size': 'medium' } }, - 'light': { 'body': { 'color': '#000', 'background': '#fff', 'font-size': 'medium' } } +const ColorSchemes = { + 'dark': { + 'color': '#d8dadc', + 'background': '#202124', + }, + 'black': { + 'color': '#d8dadc', + 'background': '#000', + }, + 'sepia': { + 'color': '#d8a262', + 'background': '#202124', + }, + 'light': { + 'color': '#000', + 'background': '#fff', + } }; -const THEME_ORDER = ['dark', 'sepia', 'light']; -const FONT_SIZES = ['x-small', 'small', 'medium', 'large', 'x-large']; + +/** + * Get Cfi from href + * @param {EpubJS.Book} book + * @param {string} href + * @returns + */ +function getCfiFromHref(book, href) { + const [_, id] = href.split('#'); + const section = book.spine.get(href); + return section?.cfiFromRange(); +} + +/** + * Flatten chapters + * @param {EpubJS.NavItem[]} chapters + * @param {EpubJS.NavItem} [parent] + * @param {number} [depth] + * @returns {object[]} + */ +function flattenChapters(chapters, parent, depth) { + return [].concat.apply([], chapters.map((chapter) => { + chapter.parent = parent; + chapter.depth = ~~depth; + return [].concat.apply([chapter], flattenChapters(chapter.subitems, chapter, chapter.depth+1)); + })); +} + +/** + * Convert float to percent string + * @param {number} percent + * @returns + */ +function percentToString(percent) { + return `${(percent * 100).toFixed(2).replace(/(\d)[\.0]+$/, '$1')}%`; +} export class BookPlayer { + #epubDialog; + #mediaElement; + #cacheStore; + #flattenedToc; + #displayConfig; + #displayConfigItems = { + colorScheme: { + label: globalize.translate('LabelTheme'), + type: 'select', + handler: e => { + this.#displayConfig.colorScheme = e.target.value; + this.#applyDisplayConfig(); + this.#saveDisplayConfig(); + }, + default: () => this.#displayConfig.colorScheme, + values: Object.keys(ColorSchemes), + }, + fontFamily: { + label: globalize.translate('LabelFont'), + type: 'select', + handler: e => this.#displayConfigCssSimpleHandler('font-family', e.target.value), + default: () => this.#displayConfigCssSimpleHandler('font-family'), + values: { + 'unset': globalize.translate('BookPlayerDisplayUnset'), + 'serif': 'serif', + 'sans-serif': 'sans-serif', + }, + }, + fontSize: { + label: globalize.translate('LabelTextSize'), + type: 'select', + handler: e => this.#displayConfigCssSimpleHandler('font-size', e.target.value), + default: () => this.#displayConfigCssSimpleHandler('font-size'), + values: { + 'unset': globalize.translate('BookPlayerDisplayUnset'), + 'x-small': globalize.translate('Smaller'), + 'small': globalize.translate('Small'), + 'medium': globalize.translate('Normal'), + 'large': globalize.translate('Large'), + 'x-large': globalize.translate('Larger'), + }, + }, + lineHeight: { + label: globalize.translate('LabelLineHeight'), + type: 'select', + handler: e => this.#displayConfigCssSimpleHandler('line-height', e.target.value), + default: () => this.#displayConfigCssSimpleHandler('line-height'), + values: { + 'unset': globalize.translate('BookPlayerDisplayUnset'), + '2.025em': '2.025em', + '2.3625em': '2.3625em', + '2.7em': '2.7em', + '3.0375em': '3.0375em', + '3.375em': '3.375em', + '3.7125em': '3.7125em', + '4.05em': '4.05em', + '4.725em': '4.725em', + '5.4em': '5.4em', + }, + }, + }; + + #loadDisplayConfig() { + try { + const loadedConfig = JSON.parse(userSettings.get('bookplayer-displayconfig', false)); + for(const key in this.#displayConfig) { + if(loadedConfig[key] === undefined) continue; + if((typeof loadedConfig[key]) !== (typeof this.#displayConfig[key])) continue; + this.#displayConfig[key] = loadedConfig[key]; + } + } catch {} + } + + /** + * + * @param {Event} + */ + #saveDisplayConfig() { + userSettings.set('bookplayer-displayconfig', JSON.stringify(this.#displayConfig), false); + } + + #applyDisplayConfig() { + const theme = { + 'body[style]': {...ColorSchemes[this.#displayConfig.colorScheme], ...this.#displayConfig.bodyCss}, + }; + this.rendition.themes.register('default', theme); + this.rendition.themes.select('default'); + } + + /** + * + * @param {string} name Name of css property + * @param {string} value + * @returns {string} + */ + #displayConfigCssSimpleHandler(name, value) { + if(value === undefined) { + return this.#displayConfig.bodyCss[name]; + } + + if(value) { + this.#displayConfig.bodyCss[name] = value; + } + else { + this.#displayConfig.bodyCss[name] = 'unset'; + } + this.#applyDisplayConfig(); + this.#saveDisplayConfig(); + } + + /** + * + * @param {EpubJS.EpubCFI} cfi + * @returns {EpubJS.NavItem} + */ + #getChapterFromCfi(cfi) { + let i; + for(i=0;i 0;i++);; + return this.#flattenedToc[i-1]; + } + + /** + * + * @param {string} query + * @returns + */ + async #getSearchResult(query) { + const {book} = this.rendition; + const resultsPerSpine = await Promise.all(book.spine.spineItems.map(item => item.load(book.load.bind(book)).then(item.find.bind(item, query)).finally(item.unload.bind(item)))); + /** @type {Object.[]} */ + const flattenedResults = []; + for(const results of resultsPerSpine) { + if(!results.length) continue; + const currentChapter = this.#getChapterFromCfi(results[0].cfi); + for(const result of results) { + flattenedResults.push(Object.assign({chapter: currentChapter}, result)); + } + } + return flattenedResults; + } + constructor() { this.name = 'Book Player'; this.type = PluginType.MediaPlayer; this.id = 'bookplayer'; this.priority = 1; - if (!userSettings.theme() || userSettings.theme() === 'dark') { - this.theme = 'dark'; - } else { - this.theme = 'light'; - } - this.fontSize = 'medium'; + + this.#displayConfig = { + bodyCss: {}, + colorScheme: ((userSettings.theme()||'dark') === 'dark') ? 'dark' : 'light', + }; + this.onDialogClosed = this.onDialogClosed.bind(this); - this.openTableOfContents = this.openTableOfContents.bind(this); - this.rotateTheme = this.rotateTheme.bind(this); - this.increaseFontSize = this.increaseFontSize.bind(this); - this.decreaseFontSize = this.decreaseFontSize.bind(this); this.previous = this.previous.bind(this); this.next = this.next.bind(this); + this.gotoPositionAsSlider = this.gotoPositionAsSlider.bind(this); this.onWindowKeyDown = this.onWindowKeyDown.bind(this); + this.onWindowWheel = this.onWindowWheel.bind(this); this.addSwipeGestures = this.addSwipeGestures.bind(this); + this.getBubbleHtml = this.getBubbleHtml.bind(this); + this.openTableOfContents = this.openTableOfContents.bind(this); + this.openDisplayConfig = this.openDisplayConfig.bind(this); + this.openSearch = this.openSearch.bind(this); } - play(options) { + async play(options) { + window._bookPlayer = this; this.progress = 0; this.cancellationToken = false; this.loaded = false; loading.show(); - const elem = this.createMediaElement(); - return this.setCurrentSrc(elem, options); + this.#cacheStore = await caches?.open('epubPlayer'); + this.#loadDisplayConfig(); + + const elem = await this.createMediaElement(options); + await this.setCurrentSrc(elem, options); } stop() { @@ -68,15 +261,9 @@ export class BookPlayer { Events.trigger(this, 'stopped', [stopInfo]); - const elem = this.mediaElement; const tocElement = this.tocElement; const rendition = this.rendition; - if (elem) { - dialogHelper.close(elem); - this.mediaElement = null; - } - if (tocElement) { tocElement.destroy(); this.tocElement = null; @@ -89,10 +276,19 @@ export class BookPlayer { // hide loader in case player was not fully loaded yet loading.hide(); this.cancellationToken = true; + + this.destroy(); } destroy() { - // Nothing to do here + document.body.classList.remove('hide-scroll'); + + const dlg = this.#epubDialog; + if (dlg) { + this.#epubDialog = null; + + dlg.parentNode.removeChild(dlg); + } } currentItem() { @@ -130,6 +326,15 @@ export class BookPlayer { return true; } + onWindowWheel(e) { + if (e.deltaY < 0) { + this.previous(); + } + else if(e.deltaY > 0) { + this.next(); + } + } + onWindowKeyDown(e) { // Skip modified keys if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return; @@ -150,16 +355,6 @@ export class BookPlayer { e.preventDefault(); this.previous(); break; - case 'Escape': - e.preventDefault(); - if (this.tocElement) { - // Close table of contents on ESC if it is open - this.tocElement.destroy(); - } else { - // Otherwise stop the entire book player - this.stop(); - } - break; } } @@ -174,54 +369,54 @@ export class BookPlayer { } bindMediaElementEvents() { - const elem = this.mediaElement; - - elem.addEventListener('close', this.onDialogClosed, { once: true }); - elem.querySelector('#btnBookplayerExit').addEventListener('click', this.onDialogClosed, { once: true }); - elem.querySelector('#btnBookplayerToc').addEventListener('click', this.openTableOfContents); - elem.querySelector('#btnBookplayerFullscreen').addEventListener('click', this.toggleFullscreen); - elem.querySelector('#btnBookplayerRotateTheme').addEventListener('click', this.rotateTheme); - elem.querySelector('#btnBookplayerIncreaseFontSize').addEventListener('click', this.increaseFontSize); - elem.querySelector('#btnBookplayerDecreaseFontSize').addEventListener('click', this.decreaseFontSize); - elem.querySelector('#btnBookplayerPrev')?.addEventListener('click', this.previous); - elem.querySelector('#btnBookplayerNext')?.addEventListener('click', this.next); + this.#epubDialog.addEventListener('close', this.onDialogClosed, { once: true }); + this.#epubDialog.querySelector('.headerBackButton').addEventListener('click', this.onDialogClosed, { once: true }); + this.#epubDialog.querySelector('.headerTocButton').addEventListener('click', this.openTableOfContents); + this.#epubDialog.querySelector('.headerFullscreenButton').addEventListener('click', this.toggleFullscreen); + this.#epubDialog.querySelector('.headerTextformatButton').addEventListener('click', this.openDisplayConfig); + this.#epubDialog.querySelector('.headerSearchButton').addEventListener('click', this.openSearch); + this.#epubDialog.querySelector('.footerPrevButton').addEventListener('click', this.previous); + this.#epubDialog.querySelector('.footerNextButton').addEventListener('click', this.next); + this.#epubDialog.querySelector('.epubPositionSlider').addEventListener('change', this.gotoPositionAsSlider); + this.#epubDialog.querySelector('.epubPositionSlider').getBubbleHtml = this.getBubbleHtml; } bindEvents() { this.bindMediaElementEvents(); - document.addEventListener('keydown', this.onWindowKeyDown); + // document.addEventListener('keydown', this.onWindowKeyDown); this.rendition?.on('keydown', this.onWindowKeyDown); + // document.addEventListener('wheel', this.onWindowWheel); + this.rendition?.on('rendered', (e, i) => i.document.addEventListener('wheel', this.onWindowWheel)); if (browser.safari) { - const player = document.getElementById('bookPlayerContainer'); - this.addSwipeGestures(player); + this.addSwipeGestures(this.#mediaElement); } else { this.rendition?.on('rendered', (e, i) => this.addSwipeGestures(i.document.documentElement)); } } unbindMediaElementEvents() { - const elem = this.mediaElement; - - elem.removeEventListener('close', this.onDialogClosed); - elem.querySelector('#btnBookplayerExit').removeEventListener('click', this.onDialogClosed); - elem.querySelector('#btnBookplayerToc').removeEventListener('click', this.openTableOfContents); - elem.querySelector('#btnBookplayerFullscreen').removeEventListener('click', this.toggleFullscreen); - elem.querySelector('#btnBookplayerRotateTheme').removeEventListener('click', this.rotateTheme); - elem.querySelector('#btnBookplayerIncreaseFontSize').removeEventListener('click', this.increaseFontSize); - elem.querySelector('#btnBookplayerDecreaseFontSize').removeEventListener('click', this.decreaseFontSize); - elem.querySelector('#btnBookplayerPrev')?.removeEventListener('click', this.previous); - elem.querySelector('#btnBookplayerNext')?.removeEventListener('click', this.next); + this.#epubDialog.removeEventListener('close', this.onDialogClosed, { once: true }); + this.#epubDialog.querySelector('.headerBackButton').removeEventListener('click', this.onDialogClosed, { once: true }); + this.#epubDialog.querySelector('.headerTocButton').removeEventListener('click', this.openTableOfContents); + this.#epubDialog.querySelector('.headerFullscreenButton').removeEventListener('click', this.toggleFullscreen); + this.#epubDialog.querySelector('.headerTextformatButton').removeEventListener('click', this.openDisplayConfig); + this.#epubDialog.querySelector('.headerSearchButton').removeEventListener('click', this.openSearch); + this.#epubDialog.querySelector('.footerPrevButton').removeEventListener('click', this.previous); + this.#epubDialog.querySelector('.footerNextButton').removeEventListener('click', this.next); + this.#epubDialog.querySelector('.epubPositionSlider').removeEventListener('change', this.gotoPositionAsSlider); } unbindEvents() { - if (this.mediaElement) { + if (this.#mediaElement) { this.unbindMediaElementEvents(); } document.removeEventListener('keydown', this.onWindowKeyDown); this.rendition?.off('keydown', this.onWindowKeyDown); + // document.removeEventListener('wheel', this.onWindowWheel); + this.rendition?.off('rendered', (e, i) => i.document.addEventListener('wheel', this.onWindowWheel)); if (!browser.safari) { this.rendition?.off('rendered', (e, i) => this.addSwipeGestures(i.document.documentElement)); @@ -230,43 +425,202 @@ export class BookPlayer { this.touchHelper?.destroy(); } - openTableOfContents() { + async openTableOfContents(e) { if (this.loaded) { - this.tocElement = new TableOfContents(this); + const currentChapter = this.#getChapterFromCfi(this.rendition.location.start.cfi) || {id: null}; + const {book} = this.rendition; + const menuOptions = { + title: globalize.translate('Toc'), + items: this.#flattenedToc.map(chapter => ({ + id: `${book.path.directory}${chapter.href.startsWith('../') ? chapter.href.slice(3) : chapter.href}`, + name: chapter.label.replace(/^\s+|\s+$/g,''), + icon: ( + currentChapter.id === chapter.id ? 'chevron_right' + : '' + ) + ( + chapter.depth > 0 ? ` indent-${Math.min(9, chapter.depth)}` + : '' + ), + asideText: percentToString(book.locations.percentageFromCfi(chapter.cfi)), + })), + positionTo: e.target, + resolveOnClick: true, + border: true + }; + + try { + const id = await actionSheet.show(menuOptions); + this.rendition.display(book.path.relative(id)); + } catch {} } } - toggleFullscreen() { - if (Screenfull.isEnabled) { - const icon = document.querySelector('#btnBookplayerFullscreen .material-icons'); - icon.classList.remove(Screenfull.isFullscreen ? 'fullscreen_exit' : 'fullscreen'); - icon.classList.add(Screenfull.isFullscreen ? 'fullscreen' : 'fullscreen_exit'); - Screenfull.toggle(); - } - } + async openSearch(e) { + let inputTimeout = null; + const displayConfigDlg = dialogHelper.createDialog({ + exitAnimationDuration: 200, + size: 'epub300', + autoFocus: false, + scrollY: false, + exitAnimation: 'fadeout', + removeOnClose: true + }); + displayConfigDlg.innerHTML = translateHtml(await import('./search.html')); + + const inputElem = displayConfigDlg.querySelector('input[type="search"]'); + const resultContainer = displayConfigDlg.querySelector('.actionSheetScroller'); + const annotations = []; + const removeAnnotations = () => { + while(annotations.length) { + const annotation = annotations.pop(); + this.rendition.annotations.remove(annotation.cfiRange, 'highlight'); + } + }; + const onSearch = async () => { + removeAnnotations(); + let currentChapter = null; + const results = (await this.#getSearchResult(inputElem.value)).map(row => { + const {cfi} = row; + annotations.push(this.rendition.annotations.highlight(cfi)); + + const button = document.createElement('button'); + button.setAttribute('is', 'emby-button'); + button.setAttribute('type', 'button'); + button.setAttribute('data-cfi', row.cfi); + button.addEventListener('click', e=>this.rendition.display(cfi)); + button.classList.add( + 'listItem', + 'listItem-button', + 'actionSheetMenuItem', + 'listItem-border', + 'emby-button', + ); + + { + const body = document.createElement('div'); + body.classList.add( + 'listItemBody', + 'actionsheetListItemBody', + ); + + if(row.chapter !== currentChapter) { + currentChapter = row.chapter; + const text = document.createElement('div'); + text.classList.add( + 'listItemBodyText', + 'actionSheetItemText', + ); + text.textContent = currentChapter.label; + body.appendChild(text); + } + + { + const text = document.createElement('div'); + text.classList.add( + 'listItemBodyText', + 'secondary', + ); + text.textContent = row.excerpt; + body.appendChild(text); + } + + button.appendChild(body); + } + return button; + }); + resultContainer.replaceChildren(...results); + }; - rotateTheme() { - if (this.loaded) { - const newTheme = THEME_ORDER[(THEME_ORDER.indexOf(this.theme) + 1) % THEME_ORDER.length]; - this.rendition.themes.register('default', THEMES[newTheme]); - this.rendition.themes.update('default'); - this.theme = newTheme; - } + inputElem.addEventListener('input', () => { + if(inputTimeout) { + clearTimeout(inputTimeout); + inputTimeout = null; + } + inputTimeout = setTimeout(onSearch, 1000); + }); + + displayConfigDlg.addEventListener('close', () => { + removeAnnotations(); + }); + + dialogHelper.open(displayConfigDlg); + + const pos = actionSheet.getPosition(e.target, {}, displayConfigDlg); + displayConfigDlg.style.position = 'fixed'; + displayConfigDlg.style.margin = '0'; + displayConfigDlg.style.left = pos.left + 'px'; + displayConfigDlg.style.top = pos.top + 'px'; } - increaseFontSize() { - if (this.loaded && this.fontSize !== FONT_SIZES[FONT_SIZES.length - 1]) { - const newFontSize = FONT_SIZES[(FONT_SIZES.indexOf(this.fontSize) + 1)]; - this.rendition.themes.fontSize(newFontSize); - this.fontSize = newFontSize; + async openDisplayConfig(e) { + const displayConfigDlg = dialogHelper.createDialog({ + exitAnimationDuration: 200, + size: 'epub300', + autoFocus: false, + scrollY: false, + exitAnimation: 'fadeout', + removeOnClose: true + }); + displayConfigDlg.innerHTML = translateHtml(await import('./textformat.html')); + displayConfigDlg.querySelector('.btnClose').addEventListener('click', e=>dialogHelper.close(displayConfigDlg)); + + const form = displayConfigDlg.querySelector('.editEpubDisplaySettingsForm'); + for(const key in this.#displayConfigItems) { + const item = this.#displayConfigItems[key]; + switch(item.type) { + case 'select':{ + const container = document.createElement('div'); + const select = document.createElement('select'); + container.classList.add('selectContainer'); + select.setAttribute('label', item.label); + select.setAttribute('is', 'emby-select'); + /** @type {Object.} */ + const values = (list=>{ + if(typeof list !== 'object') { + return {}; + } + if(list instanceof Array) { + return list.reduce((obj, curr) => { + obj[curr] = curr; + return obj; + }, {}); + } + return list; + })(item.values); + + for(const value in values) { + const label = values[value]; + const option = document.createElement('option'); + option.setAttribute('value', value); + option.textContent = label; + select.appendChild(option); + } + + select.addEventListener('change', item.handler); + + let defaultValue = item.default; + if(typeof defaultValue === 'function') { + defaultValue = defaultValue(); + } + if(typeof defaultValue === 'string') { + select.value = defaultValue; + } + + container.appendChild(select); + form.appendChild(container); + }break; + } } + + dialogHelper.open(displayConfigDlg); } - decreaseFontSize() { - if (this.loaded && this.fontSize !== FONT_SIZES[0]) { - const newFontSize = FONT_SIZES[(FONT_SIZES.indexOf(this.fontSize) - 1)]; - this.rendition.themes.fontSize(newFontSize); - this.fontSize = newFontSize; + toggleFullscreen() { + if (Screenfull.isEnabled) { + const icon = document.querySelector('#btnBookplayerFullscreen .material-icons'); + icon.classList.remove(Screenfull.isFullscreen ? 'fullscreen_exit' : 'fullscreen'); + icon.classList.add(Screenfull.isFullscreen ? 'fullscreen' : 'fullscreen_exit'); + Screenfull.toggle(); } } @@ -284,34 +638,79 @@ export class BookPlayer { } } - createMediaElement() { - let elem = this.mediaElement; - if (elem) { - return elem; + gotoPositionAsSlider(e) { + console.log(e); + const input = e.target; + if (this.rendition) { + this.rendition.display(input.value/100); } + } - elem = document.getElementById('bookPlayer'); - if (!elem) { - elem = dialogHelper.createDialog({ - exitAnimationDuration: 400, - size: 'fullscreen', - autoFocus: false, - scrollY: false, - exitAnimation: 'fadeout', - removeOnClose: true - }); + getBubbleHtml(value) { + const cfi = this.rendition.book.locations.cfiFromPercentage(value/100); + return this.#getChapterFromCfi(cfi).label; + } + + async createMediaElement(options) { + const dlg = document.querySelector('.epubPlayerContainer'); + + if (!dlg) { + await import('./style.scss'); + + loading.show(); + const playerDlg = document.createElement('div'); + playerDlg.setAttribute('dir', 'ltr'); + + playerDlg.classList.add('epubPlayerContainer'); + + if (options.fullscreen) { + playerDlg.classList.add('epubPlayerContainer-onTop'); + } + + playerDlg.innerHTML = translateHtml(await import('./template.html')); + + document.body.insertBefore(playerDlg, document.body.firstChild); + this.#epubDialog = playerDlg; + this.#mediaElement = playerDlg.querySelector('.epubPlayer'); + + if (options.fullscreen) { + // At this point, we must hide the scrollbar placeholder, so it's not being displayed while the item is being loaded + document.body.classList.add('hide-scroll'); + } - elem.id = 'bookPlayer'; - elem.innerHTML = translateHtml(html); + loading.hide(); + this.#epubDialog.querySelector('.epubMediaStatusText').textContent = globalize.translate('BookStatusFetching'); + this.#epubDialog.querySelector('.epubMediaStatus').classList.remove('hide'); - dialogHelper.open(elem); + return playerDlg; } - this.mediaElement = elem; - return elem; + return dlg; } - setCurrentSrc(elem, options) { + async #fetchEpub(url) { + const epubRequest = new Request(url); + const epubResponse = await (async () => { + const cacheResponse = await this.#cacheStore?.match(epubRequest); + const cacheLastModified = cacheResponse?.headers.get('last-modified'); + const originRequest = epubRequest.clone(); + if(cacheLastModified) { + originRequest.headers.set('if-modified-since', cacheLastModified); + } + const originResponse = await fetch(originRequest); + if(originResponse.status === 304) { + return cacheResponse; + } + if(originResponse.status >= 200 && originResponse.status < 300) { + this.#cacheStore?.put(epubRequest, originResponse.clone()); + return originResponse; + } + throw new TypeError(`Origin returned unexpected response code ${originResponse.status}`); + })() + return URL.createObjectURL(await epubResponse.blob()); + } + + async setCurrentSrc(elem, options) { const item = options.items[0]; this.item = item; this.streamInfo = { @@ -327,63 +726,68 @@ export class BookPlayer { const apiClient = ServerConnections.getApiClient(serverId); if (!Screenfull.isEnabled) { - document.getElementById('btnBookplayerFullscreen').display = 'none'; + this.#epubDialog.querySelector('.headerFullscreenButton').display = 'none'; } - return new Promise((resolve, reject) => { - import('epubjs').then(({ default: epubjs }) => { - const downloadHref = apiClient.getItemDownloadUrl(item.Id); - const book = epubjs(downloadHref, { openAs: 'epub' }); + this.#epubDialog.querySelector('.pageTitle').textContent = item.Name; + const epubBlobUrl = await this.#fetchEpub(apiClient.getItemDownloadUrl(item.Id)); + const positionSlider = this.#epubDialog.querySelector('.epubPositionSlider'); + const positionText = this.#epubDialog.querySelector('.epubPositionText'); + + this.#epubDialog.querySelector('.epubMediaStatusText').textContent = globalize.translate('BookStatusProcessing'); + + const book = new EpubJS.Book(epubBlobUrl, { + openAs: 'epub', + }); - // We need to calculate the height of the window beforehand because using 100% is not accurate when the dialog is opening. - // In addition we don't render to the full height so that we have space for the top buttons. - const clientHeight = document.body.clientHeight; - const renderHeight = clientHeight - (clientHeight * 0.0425); + const rendition = book.renderTo(this.#mediaElement, { + width: '100%', + height: '100%', + flow: 'paginated', + }); - const rendition = book.renderTo('bookPlayerContainer', { - width: '100%', - height: renderHeight, - // TODO: Add option for scrolled-doc - flow: 'paginated' - }); + this.currentSrc = epubBlobUrl; + this.rendition = rendition; - this.currentSrc = downloadHref; - this.rendition = rendition; + this.#applyDisplayConfig(); - rendition.themes.register('default', THEMES[this.theme]); - rendition.themes.select('default'); + await rendition.display(); - return rendition.display().then(() => { - const epubElem = document.querySelector('.epub-container'); - epubElem.style.opacity = '0'; + const epubElem = document.querySelector('.epub-container'); + epubElem.style.opacity = '0'; - this.bindEvents(); + this.bindEvents(); - return this.rendition.book.locations.generate(1024).then(async () => { - if (this.cancellationToken) reject(); + await this.rendition.book.locations.generate(); + if (this.cancellationToken) throw new Error; - const percentageTicks = options.startPositionTicks / 10000000; - if (percentageTicks !== 0.0) { - const resumeLocation = book.locations.cfiFromPercentage(percentageTicks); - await rendition.display(resumeLocation); - } + const percentageTicks = options.startPositionTicks / 10000000; + if (percentageTicks !== 0.0) { + const resumeLocation = book.locations.cfiFromPercentage(percentageTicks); + await rendition.display(resumeLocation); + } - this.loaded = true; - epubElem.style.opacity = ''; - rendition.on('relocated', (locations) => { - this.progress = book.locations.percentageFromCfi(locations.start.cfi); - Events.trigger(this, 'pause'); - }); - - loading.hide(); - return resolve(); - }); - }, () => { - console.error('failed to display epub'); - return reject(); - }); - }); + this.#flattenedToc = flattenChapters(book.navigation.toc).map(x=>{ + x.label = x.label.replace(/^\s+|\s+$/g,''); + x.cfi = getCfiFromHref(book, x.href); + return x; + }).filter(x=>x.cfi).sort((a,b) => EpubJS.EpubCFI.prototype.compare(a.cfi,b.cfi)); + + this.loaded = true; + epubElem.style.opacity = ''; + rendition.on('relocated', (locations) => { + if(this.progress != locations.start.percentage) { + this.progress = locations.start.percentage; + Events.trigger(this, 'pause'); + } + positionSlider.value = locations.start.percentage * 100; + positionText.textContent = percentToString(locations.start.percentage); }); + + this.#epubDialog.querySelector('.epubMediaStatus').classList.add('hide'); + this.#epubDialog.querySelector('.footerPrevButton').disabled=false; + this.#epubDialog.querySelector('.footerNextButton').disabled=false; + this.#epubDialog.querySelector('.epubPositionSlider').disabled=false; } canPlayMediaType(mediaType) { diff --git a/src/plugins/bookPlayer/search.html b/src/plugins/bookPlayer/search.html new file mode 100644 index 00000000000..42d1efec630 --- /dev/null +++ b/src/plugins/bookPlayer/search.html @@ -0,0 +1,6 @@ +
+
+ +
+
+
diff --git a/src/plugins/bookPlayer/style.scss b/src/plugins/bookPlayer/style.scss index db43730188a..ffde3a988e3 100644 --- a/src/plugins/bookPlayer/style.scss +++ b/src/plugins/bookPlayer/style.scss @@ -1,82 +1,113 @@ -#bookPlayer { - position: relative; - height: 100%; - width: 100%; - overflow: auto; - z-index: 100; - background: #fff; - +.epubPlayerContainer { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; display: flex; + align-items: center; flex-direction: column; + background: #202124; + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); - .topButtons { - z-index: 1002; - width: 100%; - color: #000; - opacity: 0.7; - } + .epubPlayerWrapper { + aspect-ratio: 1.6; + position: relative; - .bookPlayerContainer { - flex-grow: 1; + .epubPlayer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } } +} + +.epubPlayerContainer-onTop { + z-index: 1000; +} + +.epubPlayerFooter { + padding: 1em; + box-sizing: border-box; - #btnBookplayerToc { - float: left; - margin-left: 2vw; + .epubPositionText { + width: 3em; + text-align: center; } +} - #btnBookplayerExit { - float: right; - margin-right: 2vw; +@keyframes spin { + 100% { + transform: rotate(360deg); } +} - .bookplayerErrorMsg { - text-align: center; +.epubMediaStatus { + position: relative; + pointer-events: none; + + .epubMediaStatusWrapper { + position: absolute; + right: 0; + bottom: 0; + left: 0; } - #btnBookplayerPrev, - #btnBookplayerNext { - margin: 0.5vh 0.5vh; + .animate { + animation: spin 4s linear infinite; } } -#dialogToc { - background-color: white; - height: fit-content; - width: fit-content; - max-height: 80%; - max-width: 60%; - padding-right: 50px; - padding-bottom: 15px; - - .bookplayerButtonIcon { - color: black; +.actionsheetMenuItemIcon { + &.indent-1 { + padding-right: .5em !important; + } + + &.indent-2 { + padding-right: 1em !important; } - .toc li { - margin-bottom: 5px; + &.indent-3 { + padding-right: 1.5em !important; + } - list-style-type: none; - font-size: 1.2rem; - font-weight: bold; + &.indent-4 { + padding-right: 2em !important; + } - ul { - padding-left: 1.5rem; + &.indent-5 { + padding-right: 2.5em !important; + } - li { - font-weight: normal; - } - } + &.indent-6 { + padding-right: 3em !important; + } - a:link { - color: #000; - text-decoration: none; - } + &.indent-7 { + padding-right: 3.5em !important; + } - a:active, - a:hover { - color: #00a4dc; - text-decoration: none; - } + &.indent-8 { + padding-right: 4em !important; } + + &.indent-9 { + padding-right: 4.5em !important; + } +} + +.dialog-epub300 { + width: 30em; + height: 40em; + display: flex; + flex-direction: column; +} + +.editEpubDisplaySettingsForm { + padding-top: 2em; } diff --git a/src/plugins/bookPlayer/tableOfContents.js b/src/plugins/bookPlayer/tableOfContents.js deleted file mode 100644 index 4c2012d0ec3..00000000000 --- a/src/plugins/bookPlayer/tableOfContents.js +++ /dev/null @@ -1,107 +0,0 @@ -import escapeHTML from 'escape-html'; -import dialogHelper from '../../components/dialogHelper/dialogHelper'; - -export default class TableOfContents { - constructor(bookPlayer) { - this.bookPlayer = bookPlayer; - this.rendition = bookPlayer.rendition; - - this.onDialogClosed = this.onDialogClosed.bind(this); - - this.createMediaElement(); - } - - destroy() { - const elem = this.elem; - if (elem) { - this.unbindEvents(); - dialogHelper.close(elem); - } - - this.bookPlayer.tocElement = null; - } - - bindEvents() { - const elem = this.elem; - - elem.addEventListener('close', this.onDialogClosed, { once: true }); - elem.querySelector('.btnBookplayerTocClose').addEventListener('click', this.onDialogClosed, { once: true }); - } - - unbindEvents() { - const elem = this.elem; - - elem.removeEventListener('close', this.onDialogClosed); - elem.querySelector('.btnBookplayerTocClose').removeEventListener('click', this.onDialogClosed); - } - - onDialogClosed() { - this.destroy(); - } - - replaceLinks(contents, f) { - const links = contents.querySelectorAll('a[href]'); - - links.forEach((link) => { - const href = link.getAttribute('href'); - - link.onclick = () => { - f(href); - return false; - }; - }); - } - - chapterTocItem(book, chapter) { - let itemHtml = '
  • '; - - // remove parent directory reference from href to fix certain books - const link = chapter.href.startsWith('../') ? chapter.href.slice(3) : chapter.href; - itemHtml += `${escapeHTML(chapter.label)}`; - - if (chapter.subitems?.length) { - const subHtml = chapter.subitems - .map((nestedChapter) => this.chapterTocItem(book, nestedChapter)) - .join(''); - - itemHtml += `
      ${subHtml}
    `; - } - - itemHtml += '
  • '; - return itemHtml; - } - - createMediaElement() { - const rendition = this.rendition; - - const elem = dialogHelper.createDialog({ - size: 'small', - autoFocus: false, - removeOnClose: true - }); - - elem.id = 'dialogToc'; - - let tocHtml = '
    '; - tocHtml += ''; - tocHtml += '
    '; - tocHtml += '
      '; - rendition.book.navigation.forEach((chapter) => { - tocHtml += this.chapterTocItem(rendition.book, chapter); - }); - - tocHtml += '
    '; - elem.innerHTML = tocHtml; - - this.replaceLinks(elem, (href) => { - const relative = rendition.book.path.relative(href); - rendition.display(relative); - this.destroy(); - }); - - this.elem = elem; - - this.bindEvents(); - dialogHelper.open(elem); - } -} diff --git a/src/plugins/bookPlayer/template.html b/src/plugins/bookPlayer/template.html index 255b8283a20..9e59ef9dd17 100644 --- a/src/plugins/bookPlayer/template.html +++ b/src/plugins/bookPlayer/template.html @@ -1,22 +1,52 @@ -
    - - - - - - +
    +
    +
    + + +
    +
    + + + + +
    +
    -
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + +
    + +
    + +
    +
    diff --git a/src/plugins/bookPlayer/textformat.html b/src/plugins/bookPlayer/textformat.html new file mode 100644 index 00000000000..ea08acef565 --- /dev/null +++ b/src/plugins/bookPlayer/textformat.html @@ -0,0 +1,13 @@ +
    +

    ${BookPlayerDisplayPreferences}

    +
    + +
    +
    + +
    +
    +
    +
    diff --git a/src/strings/ko.json b/src/strings/ko.json index 6429055b273..20d5171b251 100644 --- a/src/strings/ko.json +++ b/src/strings/ko.json @@ -12,6 +12,12 @@ "Backdrops": "배경", "BirthDateValue": "출생: {0}", "BirthPlaceValue": "출생지: {0}", + "BookPlayerDisplayPreferences": "표시 설정", + "LabelLineHeight": "줄 간격", + "Toc": "목차", + "BookStatusFetching": "다운로드 중", + "BookStatusProcessing": "처리 중", + "BookPlayerDisplayUnset": "설정 안 함", "MessageBrowsePluginCatalog": "사용 가능한 플러그인을 보려면 플러그인 카탈로그를 참고하십시오.", "ButtonAddScheduledTaskTrigger": "트리거 추가", "ButtonAddServer": "서버 추가",