From 2ea2132740238f79136e7e398d78a9d25329c9fa Mon Sep 17 00:00:00 2001 From: MrTimscampi Date: Fri, 29 May 2020 23:32:45 +0200 Subject: [PATCH] Add barebones comic book reader --- package.json | 2 + src/bundle.js | 6 + src/components/comicsPlayer/plugin.js | 215 +++++++++++++++++++++ src/components/playback/playbackmanager.js | 6 +- src/components/pluginManager.js | 2 +- src/scripts/site.js | 4 +- webpack.common.js | 18 +- yarn.lock | 5 + 8 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 src/components/comicsPlayer/plugin.js diff --git a/package.json b/package.json index 749c62d39c..aefec20d7a 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "jellyfin-noto": "https://github.com/jellyfin/jellyfin-noto", "jquery": "^3.5.1", "jstree": "^3.3.7", + "libarchive.js": "^1.3.0", "libass-wasm": "https://github.com/jellyfin/JavascriptSubtitlesOctopus#4.0.0-jf-smarttv", "material-design-icons-iconfont": "^5.0.1", "native-promise-only": "^0.8.0-a", @@ -92,6 +93,7 @@ "src/components/autoFocuser.js", "src/components/cardbuilder/cardBuilder.js", "src/scripts/fileDownloader.js", + "src/components/comicsPlayer/plugin.js", "src/components/images/imageLoader.js", "src/components/lazyLoader/lazyLoaderIntersectionObserver.js", "src/components/playback/mediasession.js", diff --git a/src/bundle.js b/src/bundle.js index d7ba6c6a51..fd9099aaf3 100644 --- a/src/bundle.js +++ b/src/bundle.js @@ -176,3 +176,9 @@ _define('connectionManagerFactory', function () { _define('appStorage', function () { return apiclient.AppStorage; }); + +// libarchive.js +var libarchive = require('libarchive.js'); +_define('libarchive', function () { + return libarchive; +}); diff --git a/src/components/comicsPlayer/plugin.js b/src/components/comicsPlayer/plugin.js new file mode 100644 index 0000000000..43469bfed4 --- /dev/null +++ b/src/components/comicsPlayer/plugin.js @@ -0,0 +1,215 @@ +import connectionManager from 'connectionManager'; +import loading from 'loading'; +import dialogHelper from 'dialogHelper'; +import keyboardnavigation from 'keyboardnavigation'; +import appRouter from 'appRouter'; +import 'css!../slideshow/style'; +import * as libarchive from 'libarchive'; + +export class ComicsPlayer { + constructor() { + this.name = 'Comics Player'; + this.type = 'mediaplayer'; + this.id = 'comicsplayer'; + this.priority = 1; + this.imageMap = new Map(); + + this.onDialogClosed = this.onDialogClosed.bind(this); + this.onWindowKeyUp = this.onWindowKeyUp.bind(this); + } + + play(options) { + this._progress = 0; + + let elem = this.createMediaElement(); + return this.setCurrentSrc(elem, options); + } + + stop() { + this.unbindEvents(); + + let elem = this._mediaElement; + + if (elem) { + dialogHelper.close(elem); + this._mediaElement = null; + } + + // Hide loader in case player was not fully loaded yet + loading.hide(); + } + + onDialogClosed() { + this.stop(); + } + + onWindowKeyUp(e) { + let key = keyboardnavigation.getKeyName(e); + switch (key) { + case 'Escape': + this.stop(); + break; + } + } + + bindEvents() { + document.addEventListener('keyup', this.onWindowKeyUp); + } + + unbindEvents() { + document.removeEventListener('keyup', this.onWindowKeyUp); + } + + createMediaElement() { + let elem = this._mediaElement; + + if (elem) { + return elem; + } + + elem = document.getElementById('comicsPlayer'); + if (!elem) { + elem = dialogHelper.createDialog({ + exitAnimationDuration: 400, + size: 'fullscreen', + autoFocus: false, + scrollY: false, + exitAnimation: 'fadeout', + removeOnClose: true + }); + elem.id = 'bookPlayer'; + + elem.classList.add('slideshowDialog'); + + elem.innerHTML = '
'; + + this.bindEvents(); + + dialogHelper.open(elem); + } + + this._mediaElement = elem; + + return elem; + } + + setCurrentSrc(elem, options) { + let item = options.items[0]; + this._currentItem = item; + + loading.show(); + + let serverId = item.ServerId; + let apiClient = connectionManager.getApiClient(serverId); + + libarchive.Archive.init({ + workerUrl: appRouter.baseUrl() + '/libraries/worker-bundle.js' + }); + + return new Promise((resolve, reject) => { + let downloadUrl = apiClient.getItemDownloadUrl(item.Id); + const archiveSource = new ArchiveSource(downloadUrl); + var instance = this; + import('swiper').then(({default: Swiper}) => { + archiveSource.load().then(() => { + loading.hide(); + this.swiperInstance = new Swiper(elem.querySelector('.slideshowSwiperContainer'), { + direction: 'horizontal', + // Loop is disabled due to the virtual slides option not supporting it. + loop: false, + zoom: { + minRatio: 1, + toggle: true, + containerClass: 'slider-zoom-container' + }, + autoplay: false, + keyboard: { + enabled: true + }, + preloadImages: true, + slidesPerView: 1, + slidesPerColumn: 1, + initialSlide: 0, + // Virtual slides reduce memory consumption for large libraries while allowing preloading of images; + virtual: { + slides: archiveSource.urls, + cache: true, + renderSlide: instance.getImgFromUrl, + addSlidesBefore: 1, + addSlidesAfter: 1 + } + }); + }); + }); + }); + } + + getImgFromUrl(url) { + return `
+
+ +
+
`; + } + + canPlayMediaType(mediaType) { + return (mediaType || '').toLowerCase() === 'book'; + } + + canPlayItem(item) { + if (item.Path && (item.Path.endsWith('cbz') || item.Path.endsWith('cbr'))) { + return true; + } + return false; + } +} + +class ArchiveSource { + constructor(url) { + this.url = url; + this.files = []; + this.urls = []; + this.loadPromise = this.load(); + this.itemsLoaded = 0; + } + + async load() { + let res = await fetch(this.url); + if (!res.ok) { + return; + } + let blob = await res.blob(); + this.archive = await libarchive.Archive.open(blob); + this.raw = await this.archive.getFilesArray(); + this.numberOfFiles = this.raw.length; + await this.archive.extractFiles(); + + let files = await this.archive.getFilesArray(); + files.sort((a, b) => { + if (a.file.name < b.file.name) + return -1; + else + return 1; + }); + + for (let file of files) { + let url = URL.createObjectURL(file.file); + this.urls.push(url); + } + } + + getLength() { + return this.raw.length; + } + + async item(index) { + if (this.urls[index]) { + return this.urls[index]; + } + + await this.loadPromise; + return this.urls[index]; + } +} + +export default ComicsPlayer; diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index 59108cf72e..053088ef2f 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -2187,7 +2187,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla // Only used internally self.getCurrentTicks = getCurrentTicks; - function playPhotos(items, options, user) { + function playOther(items, options, user) { var playStartIndex = options.startIndex || 0; var player = getPlayer(items[playStartIndex], options); @@ -2216,9 +2216,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla return Promise.reject(); } - if (firstItem.MediaType === 'Photo') { + if (firstItem.MediaType === 'Photo' || firstItem.MediaType === 'Book') { - return playPhotos(items, options, user); + return playOther(items, options, user); } var apiClient = connectionManager.getApiClient(firstItem.ServerId); diff --git a/src/components/pluginManager.js b/src/components/pluginManager.js index 6cb56d767b..fd35d344bf 100644 --- a/src/components/pluginManager.js +++ b/src/components/pluginManager.js @@ -58,7 +58,7 @@ define(['events', 'globalize'], function (events, globalize) { return new Promise(function (resolve, reject) { require([pluginSpec], (pluginFactory) => { - var plugin = new pluginFactory(); + var plugin = pluginFactory.default ? new pluginFactory.default() : new pluginFactory(); // See if it's already installed var existing = instance.pluginsList.filter(function (p) { diff --git a/src/scripts/site.js b/src/scripts/site.js index aeb651d888..2e83928f97 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -490,6 +490,7 @@ var AppInfo = {}; 'components/playback/experimentalwarnings', 'components/htmlAudioPlayer/plugin', 'components/htmlVideoPlayer/plugin', + 'components/comicsPlayer/plugin', 'components/photoPlayer/plugin', 'components/youtubeplayer/plugin', 'components/backdropScreensaver/plugin', @@ -701,7 +702,8 @@ var AppInfo = {}; 'events', 'credentialprovider', 'connectionManagerFactory', - 'appStorage' + 'appStorage', + 'comicReader' ] }, urlArgs: urlArgs, diff --git a/webpack.common.js b/webpack.common.js index 03beb63a73..2cc8478d86 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -1,10 +1,12 @@ const path = require('path'); const CopyPlugin = require('copy-webpack-plugin'); +const WorkerPlugin = require('worker-plugin'); const Assets = [ 'alameda/alameda.js', 'native-promise-only/npo.js', + 'libarchive.js/dist/worker-bundle.js', 'libass-wasm/dist/js/subtitles-octopus-worker.js', 'libass-wasm/dist/js/subtitles-octopus-worker.data', 'libass-wasm/dist/js/subtitles-octopus-worker.wasm', @@ -13,6 +15,11 @@ const Assets = [ 'libass-wasm/dist/js/subtitles-octopus-worker-legacy.js.mem' ]; +const LibarchiveWasm = [ + 'libarchive.js/dist/wasm-gen/libarchive.js', + 'libarchive.js/dist/wasm-gen/libarchive.wasm' +]; + module.exports = { context: path.resolve(__dirname, 'src'), entry: './bundle.js', @@ -34,6 +41,15 @@ module.exports = { to: path.resolve(__dirname, './dist/libraries') }; }) - ) + ), + new CopyPlugin( + LibarchiveWasm.map(asset => { + return { + from: path.resolve(__dirname, `./node_modules/${asset}`), + to: path.resolve(__dirname, './dist/libraries/wasm-gen/') + }; + }) + ), + new WorkerPlugin() ] }; diff --git a/yarn.lock b/yarn.lock index 20fdef5de1..5b8df85f45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6783,6 +6783,11 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +libarchive.js@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/libarchive.js/-/libarchive.js-1.3.0.tgz#18c42c6b4ce727a02359c90769e4e454cf3743cd" + integrity sha512-EkQfRXt9DhWwj6BnEA2TNpOf4jTnzSTUPGgE+iFxcdNqjktY8GitbDeHnx8qZA0/IukNyyBUR3oQKRdYkO+HFg== + "libass-wasm@https://github.com/jellyfin/JavascriptSubtitlesOctopus#4.0.0-jf-smarttv": version "4.0.0" resolved "https://github.com/jellyfin/JavascriptSubtitlesOctopus#58e9a3f1a7f7883556ee002545f445a430120639"