1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00
This commit is contained in:
Yoonji Park 2025-03-30 11:01:14 -04:00 committed by GitHub
commit 4137b4e564
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 754 additions and 373 deletions

View file

@ -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,
};

View file

@ -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<this.#flattenedToc.length && EpubJS.EpubCFI.prototype.compare(cfi, this.#flattenedToc[i].cfi) > 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.<string,{cfi:EpubJS.EpubCFI,excerpt:string,chapter:EpubJS.NavItem}>[]} */
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,46 +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 {}
}
}
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);
};
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';
}
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.<string, string>} */
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);
}
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();
}
}
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;
}
}
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;
}
}
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;
}
}
previous(e) {
e?.preventDefault();
if (this.rendition) {
@ -284,34 +635,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
});
elem.id = 'bookPlayer';
elem.innerHTML = translateHtml(html);
dialogHelper.open(elem);
}
this.mediaElement = elem;
return elem;
}
setCurrentSrc(elem, options) {
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');
}
loading.hide();
this.#epubDialog.querySelector('.epubMediaStatusText').textContent = globalize.translate('BookStatusFetching');
this.#epubDialog.querySelector('.epubMediaStatus').classList.remove('hide');
return playerDlg;
}
return dlg;
}
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 +723,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');
// 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);
this.#epubDialog.querySelector('.epubMediaStatusText').textContent = globalize.translate('BookStatusProcessing');
const rendition = book.renderTo('bookPlayerContainer', {
width: '100%',
height: renderHeight,
// TODO: Add option for scrolled-doc
flow: 'paginated'
});
this.currentSrc = downloadHref;
this.rendition = rendition;
rendition.themes.register('default', THEMES[this.theme]);
rendition.themes.select('default');
return rendition.display().then(() => {
const epubElem = document.querySelector('.epub-container');
epubElem.style.opacity = '0';
this.bindEvents();
return this.rendition.book.locations.generate(1024).then(async () => {
if (this.cancellationToken) reject();
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();
});
});
const book = new EpubJS.Book(epubBlobUrl, {
openAs: 'epub',
});
const rendition = book.renderTo(this.#mediaElement, {
width: '100%',
height: '100%',
flow: 'paginated',
});
this.currentSrc = epubBlobUrl;
this.rendition = rendition;
this.#applyDisplayConfig();
await rendition.display();
const epubElem = document.querySelector('.epub-container');
epubElem.style.opacity = '0';
this.bindEvents();
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);
}
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) {

View file

@ -0,0 +1,6 @@
<div class="actionSheetContent">
<div class="inputContainer">
<input class="emby-input" type="search" placeholder="${Search}" autocomplete="off" maxlength="40">
</div>
<div class="actionSheetScroller flex-grow scrollY"></div>
</div>

View file

@ -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;
.epubPlayer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
}
.bookPlayerContainer {
flex-grow: 1;
}
.epubPlayerContainer-onTop {
z-index: 1000;
}
#btnBookplayerToc {
float: left;
margin-left: 2vw;
}
.epubPlayerFooter {
padding: 1em;
box-sizing: border-box;
#btnBookplayerExit {
float: right;
margin-right: 2vw;
}
.bookplayerErrorMsg {
.epubPositionText {
width: 3em;
text-align: center;
}
}
#btnBookplayerPrev,
#btnBookplayerNext {
margin: 0.5vh 0.5vh;
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
#dialogToc {
background-color: white;
height: fit-content;
width: fit-content;
max-height: 80%;
max-width: 60%;
padding-right: 50px;
padding-bottom: 15px;
.epubMediaStatus {
position: relative;
pointer-events: none;
.bookplayerButtonIcon {
color: black;
.epubMediaStatusWrapper {
position: absolute;
right: 0;
bottom: 0;
left: 0;
}
.toc li {
margin-bottom: 5px;
list-style-type: none;
font-size: 1.2rem;
font-weight: bold;
ul {
padding-left: 1.5rem;
li {
font-weight: normal;
}
}
a:link {
color: #000;
text-decoration: none;
}
a:active,
a:hover {
color: #00a4dc;
text-decoration: none;
}
.animate {
animation: spin 4s linear infinite;
}
}
.actionsheetMenuItemIcon {
&.indent-1 {
padding-right: .5em !important;
}
&.indent-2 {
padding-right: 1em !important;
}
&.indent-3 {
padding-right: 1.5em !important;
}
&.indent-4 {
padding-right: 2em !important;
}
&.indent-5 {
padding-right: 2.5em !important;
}
&.indent-6 {
padding-right: 3em !important;
}
&.indent-7 {
padding-right: 3.5em !important;
}
&.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;
}

View file

@ -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 = '<li>';
// remove parent directory reference from href to fix certain books
const link = chapter.href.startsWith('../') ? chapter.href.slice(3) : chapter.href;
itemHtml += `<a href="${escapeHTML(book.path.directory + link)}">${escapeHTML(chapter.label)}</a>`;
if (chapter.subitems?.length) {
const subHtml = chapter.subitems
.map((nestedChapter) => this.chapterTocItem(book, nestedChapter))
.join('');
itemHtml += `<ul>${subHtml}</ul>`;
}
itemHtml += '</li>';
return itemHtml;
}
createMediaElement() {
const rendition = this.rendition;
const elem = dialogHelper.createDialog({
size: 'small',
autoFocus: false,
removeOnClose: true
});
elem.id = 'dialogToc';
let tocHtml = '<div class="topRightActionButtons">';
tocHtml += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerTocClose hide-mouse-idle-tv" tabindex="-1"><span class="material-icons bookplayerButtonIcon close" aria-hidden="true"></span></button>';
tocHtml += '</div>';
tocHtml += '<ul class="toc">';
rendition.book.navigation.forEach((chapter) => {
tocHtml += this.chapterTocItem(rendition.book, chapter);
});
tocHtml += '</ul>';
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);
}
}

View file

@ -1,28 +1,52 @@
<div class="topButtons">
<button is="paper-icon-button-light" id="btnBookplayerToc" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon toc" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerPrev" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon navigate_before" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerNext" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon navigate_next" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerExit" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon close" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerRotateTheme" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon remove_red_eye" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerDecreaseFontSize" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon text_decrease" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerIncreaseFontSize" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon text_increase" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerFullscreen" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon fullscreen" aria-hidden="true"></span>
</button>
<div class="epubPlayerHeader w-100 skinHeader-withBackground">
<div class="flex align-items-center headerTop">
<div class="headerLeft">
<button is="paper-icon-button-light" class="headerBackButton headerButton headerButtonLeft paper-icon-button-light" title="${Previous}">
<span class="material-icons arrow_back" aria-hidden="true"></span>
</button>
<h3 class="pageTitle" aria-hidden="true"></h3>
</div>
<div class="headerRight">
<button is="paper-icon-button-light" class="headerFullscreenButton headerButton headerButtonRight paper-icon-button-light" title="${Fullscreen}">
<span class="material-icons fullscreen" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" class="headerSearchButton headerButton headerButtonRight paper-icon-button-light" title="${Search}">
<span class="material-icons search" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" class="headerTextformatButton headerButton headerButtonRight paper-icon-button-light" title="${LabelFont}">
<span class="material-icons text_format" aria-hidden="true"></span>
</button>
<button is="paper-icon-button-light" class="headerTocButton headerButton headerButtonRight paper-icon-button-light" title="${Toc}">
<span class="material-icons toc" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
<div id="bookPlayerContainer" class="bookPlayerContainer"></div>
<div class="epubPlayerWrapper flex-grow">
<div class="epubPlayer"></div>
</div>
<div class="epubPlayerFooter flex align-items-center flex-direction-column w-100">
<div class="epubMediaStatus w-100 hide">
<div class="epubMediaStatusWrapper flex justify-content-center w-100">
<div class="flex align-items-center">
<span class="material-icons animate autorenew" aria-hidden="true"></span>
<span class="epubMediaStatusText"></span>
</div>
</div>
</div>
<div class="flex align-items-center w-100">
<div class="sliderContainer flex-grow" style="margin: .5em 0 .25em;">
<div class="sliderMarkerContainer"></div>
<input type="range" step=".01" min="0" max="100" value="0" is="emby-slider" class="epubPositionSlider" data-slider-keep-progress="true" disabled>
</div>
<button is="paper-icon-button-light" class="footerPrevButton headerButton headerButtonRight paper-icon-button-light" title="${Previous}" disabled>
<span class="material-icons keyboard_arrow_left" aria-hidden="true"></span>
</button>
<div class="epubPositionText"></div>
<button is="paper-icon-button-light" class="footerNextButton headerButton headerButtonRight paper-icon-button-light" title="${Next}" disabled>
<span class="material-icons keyboard_arrow_right" aria-hidden="true"></span>
</button>
</div>
</div>

View file

@ -0,0 +1,13 @@
<div class="formDialogHeader">
<h3 class="formDialogHeaderTitle flex-grow">${BookPlayerDisplayPreferences}</h3>
<div class="dialogHeader flex align-items-center justify-content-center">
<button is="paper-icon-button-light" class="btnClose autoSize" tabindex="-1" title="${ButtonClose}">
<span class="material-icons close" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="formDialogContent">
<form class="editEpubDisplaySettingsForm dialogContentInner dialog-content-centered">
</form>
</div>

View file

@ -80,6 +80,12 @@
"BirthDateValue": "Born: {0}",
"BirthLocation": "Birth location",
"BirthPlaceValue": "Birth place: {0}",
"BookPlayerDisplayPreferences": "Display Preferences",
"LabelLineHeight": "Line height",
"Toc": "Table of Contents",
"BookStatusFetching": "Downloading",
"BookStatusProcessing": "Processing",
"BookPlayerDisplayUnset": "Keep default",
"Blacklist": "Blacklist",
"BlockContentWithTagsHelp": "Hide media with at least one of the specified tags.",
"BookLibraryHelp": "Audio and text books are supported. Review the {0} book naming guide {1}.",

View file

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