diff --git a/.ci/azure-pipelines-lint.yml b/.ci/azure-pipelines-lint.yml deleted file mode 100644 index 8d9efbd73a2..00000000000 --- a/.ci/azure-pipelines-lint.yml +++ /dev/null @@ -1,29 +0,0 @@ -jobs: -- job: Lint - displayName: 'Lint' - - pool: - vmImage: 'ubuntu-latest' - - steps: - - task: NodeTool@0 - displayName: 'Install Node' - inputs: - versionSpec: '12.x' - - - task: Cache@2 - displayName: 'Cache node_modules' - inputs: - key: 'yarn | yarn.lock' - path: 'node_modules' - - - script: 'yarn install --frozen-lockfile' - displayName: 'Install Dependencies' - env: - SKIP_PREPARE: 'true' - - - script: 'yarn run lint --quiet' - displayName: 'Run ESLint' - - - script: 'yarn run stylelint' - displayName: 'Run Stylelint' diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml index 3d7a5f1cde9..d3b77d41bc6 100644 --- a/.ci/azure-pipelines.yml +++ b/.ci/azure-pipelines.yml @@ -13,5 +13,4 @@ pr: jobs: - template: azure-pipelines-build.yml -- template: azure-pipelines-lint.yml - template: azure-pipelines-package.yml diff --git a/.dependabot/config.yml b/.dependabot/config.yml deleted file mode 100644 index 02dfd18aacc..00000000000 --- a/.dependabot/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -version: 1 -update_configs: - - package_manager: "javascript" - directory: "/" - update_schedule: "weekly" diff --git a/.eslintrc.js b/.eslintrc.js index aabfd633f8f..400c528a48c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -99,11 +99,9 @@ module.exports = { }, rules: { // TODO: Fix warnings and remove these rules - 'no-redeclare': ['off'], - 'no-useless-escape': ['off'], - 'no-unused-vars': ['off'], - // TODO: Remove after ES6 migration is complete - 'import/no-unresolved': ['off'] + 'no-redeclare': ['warn'], + 'no-useless-escape': ['warn'], + 'no-unused-vars': ['warn'] }, settings: { polyfills: [ diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 00000000000..a69f9cb4357 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000000..8b9ca0b2f06 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,95 @@ +name: Lint + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + run-eslint: + name: Run eslint + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + + - name: Install Node.js dependencies + run: yarn install --frozen-lockfile + env: + SKIP_PREPARE: true + + - name: Run eslint + run: yarn lint + + run-stylelint-css: + name: Run stylelint (css) + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + + - name: Set up stylelint matcher + uses: xt0rted/stylelint-problem-matcher@v1 + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + + - name: Install Node.js dependencies + run: yarn install --frozen-lockfile + env: + SKIP_PREPARE: true + + - name: Run stylelint + run: yarn stylelint:css + + run-stylelint-scss: + name: Run stylelint (scss) + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + + - name: Set up stylelint matcher + uses: xt0rted/stylelint-problem-matcher@v1 + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + + - name: Install Node.js dependencies + run: yarn install --frozen-lockfile + env: + SKIP_PREPARE: true + + - name: Run stylelint + run: yarn stylelint:scss diff --git a/.github/workflows/merge-conflicts.yml b/.github/workflows/merge-conflicts.yml new file mode 100644 index 00000000000..9f4d95d884d --- /dev/null +++ b/.github/workflows/merge-conflicts.yml @@ -0,0 +1,15 @@ +name: "Merge Conflicts" + +on: + push: + branches: + - master +jobs: + triage: + runs-on: ubuntu-latest + if: github.repository == 'jellyfin/jellyfin-web' + steps: + - uses: mschilde/auto-label-merge-conflicts@master + with: + CONFLICT_LABEL_NAME: "merge conflict" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.stylelintrc b/.stylelintrc.json similarity index 91% rename from .stylelintrc rename to .stylelintrc.json index a13acf428d1..0f059233d54 100644 --- a/.stylelintrc +++ b/.stylelintrc.json @@ -1,14 +1,14 @@ { "plugins": [ - "stylelint-no-browser-hacks/lib", + "stylelint-no-browser-hacks/lib" ], "rules": { "at-rule-empty-line-before": [ "always", { - except: [ + "except": [ "blockless-after-same-name-blockless", - "first-nested", + "first-nested" ], - ignore: ["after-comment"], + "ignore": ["after-comment"] } ], "at-rule-name-case": "lower", "at-rule-name-space-after": "always-single-line", @@ -26,27 +26,27 @@ "color-hex-length": "short", "color-no-invalid-hex": true, "comment-empty-line-before": [ "always", { - except: ["first-nested"], - ignore: ["stylelint-commands"], + "except": ["first-nested"], + "ignore": ["stylelint-commands"] } ], "comment-no-empty": true, "comment-whitespace-inside": "always", "custom-property-empty-line-before": [ "always", { - except: [ + "except": [ "after-custom-property", - "first-nested", + "first-nested" ], - ignore: [ + "ignore": [ "after-comment", - "inside-single-line-block", - ], + "inside-single-line-block" + ] } ], "declaration-bang-space-after": "never", "declaration-bang-space-before": "always", "declaration-block-no-duplicate-properties": [ true, { - ignore: ["consecutive-duplicates-with-different-values"] + "ignore": ["consecutive-duplicates-with-different-values"] } ], "declaration-block-no-shorthand-property-overrides": true, @@ -105,8 +105,8 @@ } ], "rule-empty-line-before": [ "always-multi-line", { - except: ["first-nested"], - ignore: ["after-comment"], + "except": ["first-nested"], + "ignore": ["after-comment"] } ], "selector-attribute-brackets-space-inside": "never", "selector-attribute-operator-space-after": "never", @@ -138,6 +138,6 @@ "value-list-comma-newline-after": "always-multi-line", "value-list-comma-space-after": "always-single-line", "value-list-comma-space-before": "never", - "value-list-max-empty-lines": 0, + "value-list-max-empty-lines": 0 } } diff --git a/.stylelintrc.scss.json b/.stylelintrc.scss.json new file mode 100644 index 00000000000..7c5b0dd401a --- /dev/null +++ b/.stylelintrc.scss.json @@ -0,0 +1,8 @@ +{ + "extends": [ "./.stylelintrc.json" ], + "plugins": [ "stylelint-scss" ], + "rules": { + "at-rule-no-unknown": null, + "scss/at-rule-no-unknown": true + } +} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 9f9be018b3e..72a13043e9f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -36,6 +36,7 @@ - [MrTimscampi](https://github.com/MrTimscampi) - [ConfusedPolarBear](https://github.com/ConfusedPolarBear) - [Sarab Singh](https://github.com/sarab97) + - [DesertCookie](https://github.com/desertcookie) - [GuilhermeHideki](https://github.com/GuilhermeHideki) - [Andrei Oanca](https://github.com/OancaAndrei) - [Cromefire_](https://github.com/cromefire) diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000000..08e71d91f59 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,20 @@ +module.exports = { + babelrcRoots: [ + // Keep the root as a root + '.' + ], + presets: [ + [ + '@babel/preset-env', + { + useBuiltIns: 'usage', + corejs: 3 + } + ] + ], + plugins: [ + '@babel/plugin-proposal-class-properties', + '@babel/plugin-proposal-private-methods', + 'babel-plugin-dynamic-import-polyfill' + ] +}; diff --git a/build.yaml b/build.yaml index a73be8ec43c..7b5b05ed8f3 100644 --- a/build.yaml +++ b/build.yaml @@ -1,7 +1,7 @@ --- # We just wrap `build` so this is really it name: "jellyfin-web" -version: "10.7.0" +version: "10.8.0" packages: - debian.all - fedora.all diff --git a/debian/changelog b/debian/changelog index ab5e13196d1..bf68fb6941a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jellyfin-web (10.8.0-1) unstable; urgency=medium + + * Forthcoming stable release + + -- Jellyfin Packaging Team Fri, 04 Dec 2020 21:58:23 -0500 + jellyfin-web (10.7.0-1) unstable; urgency=medium * Forthcoming stable release diff --git a/fedora/jellyfin-web.spec b/fedora/jellyfin-web.spec index c35a1caab28..ac3ce3bdb76 100644 --- a/fedora/jellyfin-web.spec +++ b/fedora/jellyfin-web.spec @@ -1,7 +1,7 @@ %global debug_package %{nil} Name: jellyfin-web -Version: 10.7.0 +Version: 10.8.0 Release: 1%{?dist} Summary: The Free Software Media System web client License: GPLv3 @@ -42,6 +42,8 @@ mv dist %{buildroot}%{_datadir}/jellyfin-web %{_datadir}/licenses/jellyfin/LICENSE %changelog +* Fri Dec 04 2020 Jellyfin Packaging Team +- Forthcoming stable release * Mon Jul 27 2020 Jellyfin Packaging Team - Forthcoming stable release * Mon Mar 23 2020 Jellyfin Packaging Team diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index 7d1184dbdd5..00000000000 --- a/gulpfile.js +++ /dev/null @@ -1,178 +0,0 @@ -const { src, dest, series, parallel, watch } = require('gulp'); -const browserSync = require('browser-sync').create(); -const del = require('del'); -const babel = require('gulp-babel'); -const terser = require('gulp-terser'); -const htmlmin = require('gulp-htmlmin'); -const imagemin = require('gulp-imagemin'); -const sourcemaps = require('gulp-sourcemaps'); -const mode = require('gulp-mode')({ - modes: ['development', 'production'], - default: 'development', - verbose: false -}); -const stream = require('webpack-stream'); -const inject = require('gulp-inject'); -const postcss = require('gulp-postcss'); -const sass = require('gulp-sass'); -const lazypipe = require('lazypipe'); - -sass.compiler = require('node-sass'); - -let config; -if (mode.production()) { - config = require('./webpack.prod.js'); -} else { - config = require('./webpack.dev.js'); -} - -const options = { - javascript: { - query: ['src/**/*.js', '!src/bundle.js'] - }, - css: { - query: ['src/**/*.css', 'src/**/*.scss'] - }, - html: { - query: ['src/**/*.html', '!src/index.html'] - }, - images: { - query: ['src/**/*.png', 'src/**/*.jpg', 'src/**/*.gif', 'src/**/*.svg'] - }, - copy: { - query: ['src/**/*.json', 'src/**/*.ico', 'src/**/*.mp3'] - }, - injectBundle: { - query: 'src/index.html' - } -}; - -function serve() { - browserSync.init({ - server: { - baseDir: './dist' - }, - port: 8080 - }); - - const events = ['add', 'change']; - - watch(options.javascript.query).on('all', function (event, path) { - if (events.includes(event)) { - javascript(path); - } - }); - - watch('src/bundle.js', webpack); - - watch(options.css.query).on('all', function (event, path) { - if (events.includes(event)) { - css(path); - } - }); - - watch(options.html.query).on('all', function (event, path) { - if (events.includes(event)) { - html(path); - } - }); - - watch(options.images.query).on('all', function (event, path) { - if (events.includes(event)) { - images(path); - } - }); - - watch(options.copy.query).on('all', function (event, path) { - if (events.includes(event)) { - copy(path); - } - }); - - watch(options.injectBundle.query, injectBundle); -} - -function clean() { - return del(['dist/']); -} - -const pipelineJavascript = lazypipe() - .pipe(function () { - return mode.development(sourcemaps.init({ loadMaps: true })); - }) - .pipe(function () { - return babel({ - presets: [ - ['@babel/preset-env'] - ] - }); - }) - .pipe(function () { - return terser({ - keep_fnames: true, - mangle: false - }); - }) - .pipe(function () { - return mode.development(sourcemaps.write('.')); - }); - -function javascript(query) { - return src(typeof query !== 'function' ? query : options.javascript.query, { base: './src/' }) - .pipe(pipelineJavascript()) - .pipe(dest('dist/')) - .pipe(browserSync.stream()); -} - -function webpack() { - return stream(config) - .pipe(dest('dist/')) - .pipe(browserSync.stream()); -} - -function css(query) { - return src(typeof query !== 'function' ? query : options.css.query, { base: './src/' }) - .pipe(mode.development(sourcemaps.init({ loadMaps: true }))) - .pipe(sass().on('error', sass.logError)) - .pipe(postcss()) - .pipe(mode.development(sourcemaps.write('.'))) - .pipe(dest('dist/')) - .pipe(browserSync.stream()); -} - -function html(query) { - return src(typeof query !== 'function' ? query : options.html.query, { base: './src/' }) - .pipe(mode.production(htmlmin({ collapseWhitespace: true }))) - .pipe(dest('dist/')) - .pipe(browserSync.stream()); -} - -function images(query) { - return src(typeof query !== 'function' ? query : options.images.query, { base: './src/' }) - .pipe(mode.production(imagemin())) - .pipe(dest('dist/')) - .pipe(browserSync.stream()); -} - -function copy(query) { - return src(typeof query !== 'function' ? query : options.copy.query, { base: './src/' }) - .pipe(dest('dist/')) - .pipe(browserSync.stream()); -} - -function injectBundle() { - return src(options.injectBundle.query, { base: './src/' }) - .pipe(inject( - src(['src/scripts/apploader.js'], { read: false }, { base: './src/' }), { - relative: true, - transform: function (filepath) { - return ``; - } - } - )) - .pipe(dest('dist/')) - .pipe(browserSync.stream()); -} - -exports.default = series(clean, parallel(javascript, webpack, css, html, images, copy), injectBundle); -exports.serve = series(exports.default, serve); diff --git a/package.json b/package.json index c7da5adb116..83dad179830 100644 --- a/package.json +++ b/package.json @@ -5,75 +5,61 @@ "repository": "https://github.com/jellyfin/jellyfin-web", "license": "GPL-2.0-or-later", "devDependencies": { - "@babel/core": "^7.12.3", + "@babel/core": "^7.12.9", "@babel/eslint-parser": "^7.12.1", "@babel/eslint-plugin": "^7.12.1", "@babel/plugin-proposal-class-properties": "^7.10.1", "@babel/plugin-proposal-private-methods": "^7.12.1", - "@babel/preset-env": "^7.12.1", + "@babel/plugin-transform-modules-umd": "^7.12.1", + "@babel/preset-env": "^7.12.7", + "@uupaa/dynamic-import-polyfill": "^1.0.2", "autoprefixer": "^9.8.6", - "babel-loader": "^8.2.1", - "browser-sync": "^2.26.13", + "babel-loader": "^8.2.2", + "babel-plugin-dynamic-import-polyfill": "^1.0.0", "clean-webpack-plugin": "^3.0.0", "confusing-browser-globals": "^1.0.10", - "copy-webpack-plugin": "^6.0.3", + "copy-webpack-plugin": "^6.3.2", "css-loader": "^5.0.1", "cssnano": "^4.1.10", - "del": "^6.0.0", - "eslint": "^7.13.0", + "eslint": "^7.15.0", "eslint-plugin-compat": "^3.5.1", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-import": "^2.22.1", "eslint-plugin-promise": "^4.2.1", - "expose-loader": "^1.0.1", + "expose-loader": "^1.0.3", "file-loader": "^6.2.0", - "gulp": "^4.0.2", - "gulp-babel": "^8.0.0", - "gulp-cli": "^2.3.0", - "gulp-concat": "^2.6.1", - "gulp-htmlmin": "^5.0.1", - "gulp-if": "^3.0.0", - "gulp-imagemin": "^7.1.0", - "gulp-inject": "^5.0.5", - "gulp-mode": "^1.0.2", - "gulp-postcss": "^8.0.0", - "gulp-sass": "^4.0.2", - "gulp-sourcemaps": "^3.0.0", - "gulp-terser": "^1.4.1", "html-loader": "^1.1.0", "html-webpack-plugin": "^4.5.0", - "lazypipe": "^1.0.2", - "node-sass": "^5.0.0", "postcss-loader": "^3.0.0", "postcss-preset-env": "^6.7.0", - "source-map-loader": "^1.1.1", + "sass": "^1.30.0", + "sass-loader": "^10.1.0", + "source-map-loader": "^1.1.3", "style-loader": "^2.0.0", - "stylelint": "^13.7.2", + "stylelint": "^13.8.0", "stylelint-config-rational-order": "^0.1.2", "stylelint-no-browser-hacks": "^1.2.1", "stylelint-order": "^4.1.0", - "webpack": "^5.4.0", + "stylelint-scss": "^3.18.0", + "webpack": "^5.10.0", "webpack-cli": "^4.0.0", "webpack-dev-server": "^3.11.0", "webpack-merge": "^4.2.2", - "webpack-stream": "^6.1.1", "workbox-webpack-plugin": "^5.1.4", "worker-plugin": "^5.0.0" }, "dependencies": { - "alameda": "^1.4.0", "blurhash": "^1.1.3", "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", - "core-js": "^3.7.0", + "core-js": "^3.8.1", "date-fns": "^2.16.1", "epubjs": "^0.3.85", "fast-text-encoding": "^1.0.3", "flv.js": "^1.5.0", "headroom.js": "^0.12.0", "hls.js": "^0.14.16", - "howler": "^2.2.1", "intersection-observer": "^0.11.0", - "jellyfin-apiclient": "^1.4.2", + "jellyfin-apiclient": "^1.5.0", "jellyfin-noto": "https://github.com/jellyfin/jellyfin-noto", "jquery": "^3.5.1", "jstree": "^3.3.10", @@ -84,8 +70,6 @@ "page": "^1.11.6", "pdfjs-dist": "2.5.207", "resize-observer-polyfill": "^1.5.1", - "sass": "^1.29.0", - "sass-loader": "^10.0.5", "screenfull": "^5.0.2", "sortablejs": "^1.12.0", "swiper": "^6.3.5", @@ -94,21 +78,6 @@ "workbox-core": "^5.1.4", "workbox-precaching": "^5.1.4" }, - "babel": { - "presets": [ - [ - "@babel/preset-env", - { - "useBuiltIns": "usage", - "corejs": 3 - } - ] - ], - "plugins": [ - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-private-methods" - ] - }, "browserslist": [ "last 2 Firefox versions", "last 2 Chrome versions", @@ -131,7 +100,9 @@ "prepare": "./scripts/prepare.sh", "build:development": "webpack --config webpack.dev.js", "build:production": "webpack --config webpack.prod.js", - "lint": "eslint \".\"", - "stylelint": "stylelint \"src/**/*.css\"" + "lint": "eslint \"src/\"", + "stylelint": "yarn stylelint:css && yarn stylelint:scss", + "stylelint:css": "stylelint \"src/**/*.css\"", + "stylelint:scss": "stylelint --config=\".stylelintrc.scss.json\" \"src/**/*.scss\"" } } diff --git a/src/assets/css/fonts.scss b/src/assets/css/fonts.scss index 32dc2e7bd6c..f7aeff76e3f 100644 --- a/src/assets/css/fonts.scss +++ b/src/assets/css/fonts.scss @@ -5,7 +5,7 @@ } html { - @include font; + @include font($size: 93%); text-size-adjust: 100%; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; diff --git a/src/assets/css/librarybrowser.css b/src/assets/css/librarybrowser.css index c9ee82c8a0e..58356366be0 100644 --- a/src/assets/css/librarybrowser.css +++ b/src/assets/css/librarybrowser.css @@ -1057,7 +1057,7 @@ div.itemDetailGalleryLink.defaultCardBackground { .sectionTitleButton, .sectionTitleIconButton { margin-right: 0 !important; - display: inline-block; + display: inline-flex; vertical-align: middle; } diff --git a/src/assets/css/videoosd.css b/src/assets/css/videoosd.css index b2446d5d48f..1c1fe2a5a53 100644 --- a/src/assets/css/videoosd.css +++ b/src/assets/css/videoosd.css @@ -255,3 +255,118 @@ display: none !important; } } + +.syncPlayContainer { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; +} + +.primary-icon { + position: absolute; + font-size: 64px; + align-self: center; +} + +.primary-icon.spin { + font-size: 76px !important; + animation: spin 2s linear infinite; +} + +.secondary-icon { + position: absolute; + font-size: 24px; +} + +.secondary-icon.centered { + font-size: 28px !important; + align-self: center; +} + +.secondary-icon.shifted { + right: 0; + bottom: 0; + font-size: 52px; +} + +.syncPlayIconCircle { + position: relative; + visibility: hidden; + display: flex; + justify-content: center; + + border-radius: 50%; + margin: 60px; + height: 96px; + width: 96px; + + color: rgba(0, 164, 220, 0); + background: rgba(0, 164, 220, 0); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0); + transform: scale(1); +} + +.syncPlayIconCircle.oneShotPulse { + animation: pulse 1.5s 1; +} + +.syncPlayIconCircle.infinitePulse { + animation: infinite-pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(0.95); + color: rgba(0, 164, 220, 0.7); + background: rgba(0, 164, 220, 0.3); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0.3); + } + + 70% { + transform: scale(1); + color: rgba(0, 164, 220, 0); + background: rgba(0, 164, 220, 0); + box-shadow: 0 0 0 60px rgba(0, 164, 220, 0); + } + + 100% { + transform: scale(0.95); + color: rgba(0, 164, 220, 0); + background: rgba(0, 164, 220, 0); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0); + } +} + +@keyframes infinite-pulse { + 0% { + transform: scale(0.95); + color: rgba(0, 164, 220, 0.7); + background: rgba(0, 164, 220, 0.3); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0.3); + } + + 70% { + transform: scale(1); + color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0); + box-shadow: 0 0 0 60px rgba(0, 164, 220, 0); + } + + 100% { + transform: scale(0.95); + color: rgba(0, 164, 220, 0.7); + background: rgba(0, 164, 220, 0.3); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0); + } +} + +@keyframes spin { + 100% { + transform: rotate(-360deg); + } +} diff --git a/src/components/accessSchedule/accessSchedule.js b/src/components/accessSchedule/accessSchedule.js index 9e0e3d5cf99..15053060d58 100644 --- a/src/components/accessSchedule/accessSchedule.js +++ b/src/components/accessSchedule/accessSchedule.js @@ -12,6 +12,7 @@ import globalize from '../../scripts/globalize'; import '../../elements/emby-select/emby-select'; import '../../elements/emby-button/paper-icon-button-light'; import '../formdialog.css'; +import template from './accessSchedule.template.html'; function getDisplayTime(hours) { let minutes = 0; @@ -60,33 +61,31 @@ import '../formdialog.css'; export function show(options) { return new Promise((resolve, reject) => { - import('./accessSchedule.template.html').then(({default: template}) => { - const dlg = dialogHelper.createDialog({ - removeOnClose: true, - size: 'small' - }); - dlg.classList.add('formDialog'); - let html = ''; - html += globalize.translateHtml(template); - dlg.innerHTML = html; - populateHours(dlg); - loadSchedule(dlg, options.schedule); - dialogHelper.open(dlg); - dlg.addEventListener('close', () => { - if (dlg.submitted) { - resolve(options.schedule); - } else { - reject(); - } - }); - dlg.querySelector('.btnCancel').addEventListener('click', () => { - dialogHelper.close(dlg); - }); - dlg.querySelector('form').addEventListener('submit', event => { - submitSchedule(dlg, options); - event.preventDefault(); - return false; - }); + const dlg = dialogHelper.createDialog({ + removeOnClose: true, + size: 'small' + }); + dlg.classList.add('formDialog'); + let html = ''; + html += globalize.translateHtml(template); + dlg.innerHTML = html; + populateHours(dlg); + loadSchedule(dlg, options.schedule); + dialogHelper.open(dlg); + dlg.addEventListener('close', () => { + if (dlg.submitted) { + resolve(options.schedule); + } else { + reject(); + } + }); + dlg.querySelector('.btnCancel').addEventListener('click', () => { + dialogHelper.close(dlg); + }); + dlg.querySelector('form').addEventListener('submit', event => { + submitSchedule(dlg, options); + event.preventDefault(); + return false; }); }); } diff --git a/src/components/appRouter.js b/src/components/appRouter.js index 03000ebaf6c..c02c7edd329 100644 --- a/src/components/appRouter.js +++ b/src/components/appRouter.js @@ -20,7 +20,6 @@ class AppRouter { currentViewLoadRequest; firstConnectionResult; forcedLogoutMsg; - handleAnchorClick = page.clickHandler; isDummyBackToHome; msgTimeout; popstateOccurred = false; @@ -51,6 +50,12 @@ class AppRouter { } this.setBaseRoute(); + + // paths that start with a hashbang (i.e. /#!/page.html) get transformed to starting with // + // we need to strip one "/" for our routes to work + page('//*', (ctx) => { + page.redirect(ctx.path.substring(1)); + }); } /** @@ -118,6 +123,11 @@ class AppRouter { } show(path, options) { + // ensure the path does not start with '#!' since the router adds this + if (path.startsWith('#!')) { + path = path.substring(2); + } + if (path.indexOf('/') !== 0 && path.indexOf('://') === -1) { path = '/' + path; } @@ -503,7 +513,7 @@ class AppRouter { this.firstConnectionResult = null; if (firstResult && firstResult.State === 'ServerSignIn') { - const url = ApiClient.serverAddress() + '/System/Info/Public'; + const url = firstResult.ApiClient.serverAddress() + '/System/Info/Public'; fetch(url).then(response => { if (!response.ok) return Promise.reject('fetch failed'); return response.json(); @@ -682,27 +692,27 @@ class AppRouter { const serverId = item.ServerId || options.serverId; if (item === 'settings') { - return 'mypreferencesmenu.html'; + return '#!/mypreferencesmenu.html'; } if (item === 'wizard') { - return 'wizardstart.html'; + return '#!/wizardstart.html'; } if (item === 'manageserver') { - return 'dashboard.html'; + return '#!/dashboard.html'; } if (item === 'recordedtv') { - return 'livetv.html?tab=3&serverId=' + options.serverId; + return '#!/livetv.html?tab=3&serverId=' + options.serverId; } if (item === 'nextup') { - return 'list.html?type=nextup&serverId=' + options.serverId; + return '#!/list.html?type=nextup&serverId=' + options.serverId; } if (item === 'list') { - let url = 'list.html?serverId=' + options.serverId + '&type=' + options.itemTypes; + let url = '#!/list.html?serverId=' + options.serverId + '&type=' + options.itemTypes; if (options.isFavorite) { url += '&IsFavorite=true'; @@ -713,57 +723,57 @@ class AppRouter { if (item === 'livetv') { if (options.section === 'programs') { - return 'livetv.html?tab=0&serverId=' + options.serverId; + return '#!/livetv.html?tab=0&serverId=' + options.serverId; } if (options.section === 'guide') { - return 'livetv.html?tab=1&serverId=' + options.serverId; + return '#!/livetv.html?tab=1&serverId=' + options.serverId; } if (options.section === 'movies') { - return 'list.html?type=Programs&IsMovie=true&serverId=' + options.serverId; + return '#!/list.html?type=Programs&IsMovie=true&serverId=' + options.serverId; } if (options.section === 'shows') { - return 'list.html?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + options.serverId; + return '#!/list.html?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + options.serverId; } if (options.section === 'sports') { - return 'list.html?type=Programs&IsSports=true&serverId=' + options.serverId; + return '#!/list.html?type=Programs&IsSports=true&serverId=' + options.serverId; } if (options.section === 'kids') { - return 'list.html?type=Programs&IsKids=true&serverId=' + options.serverId; + return '#!/list.html?type=Programs&IsKids=true&serverId=' + options.serverId; } if (options.section === 'news') { - return 'list.html?type=Programs&IsNews=true&serverId=' + options.serverId; + return '#!/list.html?type=Programs&IsNews=true&serverId=' + options.serverId; } if (options.section === 'onnow') { - return 'list.html?type=Programs&IsAiring=true&serverId=' + options.serverId; + return '#!/list.html?type=Programs&IsAiring=true&serverId=' + options.serverId; } if (options.section === 'dvrschedule') { - return 'livetv.html?tab=4&serverId=' + options.serverId; + return '#!/livetv.html?tab=4&serverId=' + options.serverId; } if (options.section === 'seriesrecording') { - return 'livetv.html?tab=5&serverId=' + options.serverId; + return '#!/livetv.html?tab=5&serverId=' + options.serverId; } - return 'livetv.html?serverId=' + options.serverId; + return '#!/livetv.html?serverId=' + options.serverId; } if (itemType == 'SeriesTimer') { - return 'details?seriesTimerId=' + id + '&serverId=' + serverId; + return '#!/details?seriesTimerId=' + id + '&serverId=' + serverId; } if (item.CollectionType == 'livetv') { - return 'livetv.html'; + return '#!/livetv.html'; } if (item.Type === 'Genre') { - url = 'list.html?genreId=' + item.Id + '&serverId=' + serverId; + url = '#!/list.html?genreId=' + item.Id + '&serverId=' + serverId; if (context === 'livetv') { url += '&type=Programs'; @@ -777,7 +787,7 @@ class AppRouter { } if (item.Type === 'MusicGenre') { - url = 'list.html?musicGenreId=' + item.Id + '&serverId=' + serverId; + url = '#!/list.html?musicGenreId=' + item.Id + '&serverId=' + serverId; if (options.parentId) { url += '&parentId=' + options.parentId; @@ -787,7 +797,7 @@ class AppRouter { } if (item.Type === 'Studio') { - url = 'list.html?studioId=' + item.Id + '&serverId=' + serverId; + url = '#!/list.html?studioId=' + item.Id + '&serverId=' + serverId; if (options.parentId) { url += '&parentId=' + options.parentId; @@ -798,7 +808,7 @@ class AppRouter { if (context !== 'folders' && !itemHelper.isLocalItem(item)) { if (item.CollectionType == 'movies') { - url = 'movies.html?topParentId=' + item.Id; + url = '#!/movies.html?topParentId=' + item.Id; if (options && options.section === 'latest') { url += '&tab=1'; @@ -808,7 +818,7 @@ class AppRouter { } if (item.CollectionType == 'tvshows') { - url = 'tv.html?topParentId=' + item.Id; + url = '#!/tv.html?topParentId=' + item.Id; if (options && options.section === 'latest') { url += '&tab=2'; @@ -818,31 +828,31 @@ class AppRouter { } if (item.CollectionType == 'music') { - return 'music.html?topParentId=' + item.Id; + return '#!/music.html?topParentId=' + item.Id; } } const itemTypes = ['Playlist', 'TvChannel', 'Program', 'BoxSet', 'MusicAlbum', 'MusicGenre', 'Person', 'Recording', 'MusicArtist']; if (itemTypes.indexOf(itemType) >= 0) { - return 'details?id=' + id + '&serverId=' + serverId; + return '#!/details?id=' + id + '&serverId=' + serverId; } const contextSuffix = context ? '&context=' + context : ''; if (itemType == 'Series' || itemType == 'Season' || itemType == 'Episode') { - return 'details?id=' + id + contextSuffix + '&serverId=' + serverId; + return '#!/details?id=' + id + contextSuffix + '&serverId=' + serverId; } if (item.IsFolder) { if (id) { - return 'list.html?parentId=' + id + '&serverId=' + serverId; + return '#!/list.html?parentId=' + id + '&serverId=' + serverId; } return '#'; } - return 'details?id=' + id + '&serverId=' + serverId; + return '#!/details?id=' + id + '&serverId=' + serverId; } } diff --git a/src/components/apphost.js b/src/components/apphost.js index 8e8cb15b32e..aefdee7340d 100644 --- a/src/components/apphost.js +++ b/src/components/apphost.js @@ -7,6 +7,9 @@ import * as webSettings from '../scripts/settings/webSettings'; import globalize from '../scripts/globalize'; import profileBuilder from '../scripts/browserDeviceProfile'; +const appName = 'Jellyfin Web'; +const appVersion = '10.7.0'; + function getBaseProfileOptions(item) { const disableHlsVideoAudioCodecs = []; @@ -31,7 +34,7 @@ function getDeviceProfile(item, options = {}) { let profile; if (window.NativeShell) { - profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder); + profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, appVersion); } else { const builderOpts = getBaseProfileOptions(item); profile = profileBuilder(builderOpts); @@ -316,8 +319,6 @@ function askForExit() { let deviceId; let deviceName; -const appName = 'Jellyfin Web'; -const appVersion = '10.7.0'; export const appHost = { getWindowState: function () { diff --git a/src/components/cardbuilder/cardBuilder.js b/src/components/cardbuilder/cardBuilder.js index 4095bea48fb..b34c3969a48 100644 --- a/src/components/cardbuilder/cardBuilder.js +++ b/src/components/cardbuilder/cardBuilder.js @@ -1418,26 +1418,28 @@ import ServerConnections from '../ServerConnections'; const mediaTypeData = item.MediaType ? (' data-mediatype="' + item.MediaType + '"') : ''; const collectionTypeData = item.CollectionType ? (' data-collectiontype="' + item.CollectionType + '"') : ''; const channelIdData = item.ChannelId ? (' data-channelid="' + item.ChannelId + '"') : ''; + const pathData = item.Path ? (' data-path="' + item.Path + '"') : ''; const contextData = options.context ? (' data-context="' + options.context + '"') : ''; const parentIdData = options.parentId ? (' data-parentid="' + options.parentId + '"') : ''; + const startDate = item.StartDate ? (' data-startdate="' + item.StartDate.toString() + '"') : ''; + const endDate = item.EndDate ? (' data-enddate="' + item.EndDate.toString() + '"') : ''; let additionalCardContent = ''; if (layoutManager.desktop && !options.disableHoverMenu) { - additionalCardContent += getHoverMenuHtml(item, action, options); + additionalCardContent += getHoverMenuHtml(item, action); } - return '<' + tagName + ' data-index="' + index + '"' + timerAttributes + actionAttribute + ' data-isfolder="' + (item.IsFolder || false) + '" data-serverid="' + (item.ServerId || options.serverId) + '" data-id="' + (item.Id || item.ItemId) + '" data-type="' + item.Type + '"' + mediaTypeData + collectionTypeData + channelIdData + positionTicksData + collectionIdData + playlistIdData + contextData + parentIdData + ' data-prefix="' + prefix + '" class="' + className + '">' + cardImageContainerOpen + innerCardFooter + cardImageContainerClose + overlayButtons + additionalCardContent + cardScalableClose + outerCardFooter + cardBoxClose + ''; + return '<' + tagName + ' data-index="' + index + '"' + timerAttributes + actionAttribute + ' data-isfolder="' + (item.IsFolder || false) + '" data-serverid="' + (item.ServerId || options.serverId) + '" data-id="' + (item.Id || item.ItemId) + '" data-type="' + item.Type + '"' + mediaTypeData + collectionTypeData + channelIdData + pathData + positionTicksData + collectionIdData + playlistIdData + contextData + parentIdData + startDate + endDate + ' data-prefix="' + prefix + '" class="' + className + '">' + cardImageContainerOpen + innerCardFooter + cardImageContainerClose + overlayButtons + additionalCardContent + cardScalableClose + outerCardFooter + cardBoxClose + ''; } /** * Generates HTML markup for the card overlay. * @param {object} item - Item used to generate the card overlay. * @param {string} action - Action assigned to the overlay. - * @param {Array} options - Card builder options. * @returns {string} HTML markup of the card overlay. */ - function getHoverMenuHtml(item, action, options) { + function getHoverMenuHtml(item, action) { let html = ''; html += '
'; diff --git a/src/components/dialog/dialog.js b/src/components/dialog/dialog.js index ee97fff8a1b..6647dda400c 100644 --- a/src/components/dialog/dialog.js +++ b/src/components/dialog/dialog.js @@ -9,13 +9,15 @@ import '../../elements/emby-button/paper-icon-button-light'; import '../../elements/emby-input/emby-input'; import '../formdialog.css'; import '../../assets/css/flexstyles.scss'; +import template from './dialog.template.html'; /* eslint-disable indent */ - function showDialog(options, template) { + function showDialog(options = { dialogOptions: {}, buttons: [] }) { const dialogOptions = { removeOnClose: true, - scrollY: false + scrollY: false, + ...options.dialogOptions }; const enableTvLayout = layoutManager.tv; @@ -117,7 +119,7 @@ import '../../assets/css/flexstyles.scss'; }); } - export async function show(text, title) { + export function show(text, title) { let options; if (typeof text === 'string') { options = { @@ -128,10 +130,7 @@ import '../../assets/css/flexstyles.scss'; options = text; } - const { default: template } = await import('./dialog.template.html'); - return new Promise((resolve, reject) => { - showDialog(options, template).then(resolve, reject); - }); + return showDialog(options); } /* eslint-enable indent */ diff --git a/src/components/dialogHelper/dialogHelper.js b/src/components/dialogHelper/dialogHelper.js index cdaf47996f8..69f5677cfbc 100644 --- a/src/components/dialogHelper/dialogHelper.js +++ b/src/components/dialogHelper/dialogHelper.js @@ -360,14 +360,17 @@ import '../../assets/css/scrollstyles.css'; }); } - export function createDialog(options) { - options = options || {}; - + export function createDialog(options = {}) { // If there's no native dialog support, use a plain div // Also not working well in samsung tizen browser, content inside not clickable // Just go ahead and always use a plain div because we're seeing issues overlaying absoltutely positioned content over a modal dialog const dlg = document.createElement('div'); + // Add an id so we can access the dialog element + if (options.id) { + dlg.id = options.id; + } + dlg.classList.add('focuscontainer'); dlg.classList.add('hide'); diff --git a/src/components/displaySettings/displaySettings.js b/src/components/displaySettings/displaySettings.js index 9d7292547db..289fa40d505 100644 --- a/src/components/displaySettings/displaySettings.js +++ b/src/components/displaySettings/displaySettings.js @@ -13,6 +13,7 @@ import '../../elements/emby-checkbox/emby-checkbox'; import '../../elements/emby-button/emby-button'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; +import template from './displaySettings.template.html'; /* eslint-disable indent */ @@ -197,8 +198,7 @@ import toast from '../toast/toast'; return false; } - async function embed(options, self) { - const { default: template } = await import('./displaySettings.template.html'); + function embed(options, self) { options.element.innerHTML = globalize.translateHtml(template, 'core'); options.element.querySelector('form').addEventListener('submit', onSubmit.bind(self)); if (options.enableSaveButton) { diff --git a/src/components/favoriteitems.js b/src/components/favoriteitems.js index cb1b61c43f8..46a415b042f 100644 --- a/src/components/favoriteitems.js +++ b/src/components/favoriteitems.js @@ -141,7 +141,7 @@ import '../elements/emby-itemscontainer/emby-itemscontainer'; if (result.Items.length) { if (html += '
', !layoutManager.tv && options.Limit && result.Items.length >= options.Limit) { - html += ''; + html += ''; html += '

'; html += globalize.translate(section.name); html += '

'; diff --git a/src/components/filterdialog/filterdialog.js b/src/components/filterdialog/filterdialog.js index c8b81066d2d..77026e72d78 100644 --- a/src/components/filterdialog/filterdialog.js +++ b/src/components/filterdialog/filterdialog.js @@ -6,6 +6,7 @@ import '../../elements/emby-checkbox/emby-checkbox'; import '../../elements/emby-collapse/emby-collapse'; import './style.css'; import ServerConnections from '../ServerConnections'; +import template from './filterdialog.template.html'; /* eslint-disable indent */ function renderOptions(context, selector, cssClass, items, isCheckedFn) { @@ -402,28 +403,26 @@ import ServerConnections from '../ServerConnections'; } show() { - return import('./filterdialog.template.html').then(({default: template}) => { - return new Promise((resolve) => { - const dlg = dialogHelper.createDialog({ - removeOnClose: true, - modal: false - }); - dlg.classList.add('ui-body-a'); - dlg.classList.add('background-theme-a'); - dlg.classList.add('formDialog'); - dlg.classList.add('filterDialog'); - dlg.innerHTML = globalize.translateHtml(template); - setVisibility(dlg, this.options); - dialogHelper.open(dlg); - dlg.addEventListener('close', resolve); - updateFilterControls(dlg, this.options); - this.bindEvents(dlg); - if (enableDynamicFilters(this.options.mode)) { - dlg.classList.add('dynamicFilterDialog'); - const apiClient = ServerConnections.getApiClient(this.options.serverId); - loadDynamicFilters(dlg, apiClient, apiClient.getCurrentUserId(), this.options.query); - } + return new Promise((resolve) => { + const dlg = dialogHelper.createDialog({ + removeOnClose: true, + modal: false }); + dlg.classList.add('ui-body-a'); + dlg.classList.add('background-theme-a'); + dlg.classList.add('formDialog'); + dlg.classList.add('filterDialog'); + dlg.innerHTML = globalize.translateHtml(template); + setVisibility(dlg, this.options); + dialogHelper.open(dlg); + dlg.addEventListener('close', resolve); + updateFilterControls(dlg, this.options); + this.bindEvents(dlg); + if (enableDynamicFilters(this.options.mode)) { + dlg.classList.add('dynamicFilterDialog'); + const apiClient = ServerConnections.getApiClient(this.options.serverId); + loadDynamicFilters(dlg, apiClient, apiClient.getCurrentUserId(), this.options.query); + } }); } } diff --git a/src/components/filtermenu/filtermenu.js b/src/components/filtermenu/filtermenu.js index f4f6ef2d9bb..37d7860c029 100644 --- a/src/components/filtermenu/filtermenu.js +++ b/src/components/filtermenu/filtermenu.js @@ -14,6 +14,7 @@ import 'material-design-icons-iconfont'; import '../formdialog.css'; import '../../assets/css/flexstyles.scss'; import ServerConnections from '../ServerConnections'; +import template from './filtermenu.template.html'; function onSubmit(e) { e.preventDefault(); @@ -210,75 +211,73 @@ function loadDynamicFilters(context, options) { class FilterMenu { show(options) { return new Promise( (resolve, reject) => { - import('./filtermenu.template.html').then(({ default: template }) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); + dlg.classList.add('formDialog'); - let html = ''; + let html = ''; - html += '
'; - html += ''; - html += '

${Filters}

'; + html += '
'; + html += ''; + html += '

${Filters}

'; - html += '
'; + html += '
'; - html += template; + html += template; - dlg.innerHTML = globalize.translateHtml(html, 'core'); + dlg.innerHTML = globalize.translateHtml(html, 'core'); - const settingElements = dlg.querySelectorAll('.viewSetting'); - for (let i = 0, length = settingElements.length; i < length; i++) { - if (options.visibleSettings.indexOf(settingElements[i].getAttribute('data-settingname')) === -1) { - settingElements[i].classList.add('hide'); - } else { - settingElements[i].classList.remove('hide'); - } + const settingElements = dlg.querySelectorAll('.viewSetting'); + for (let i = 0, length = settingElements.length; i < length; i++) { + if (options.visibleSettings.indexOf(settingElements[i].getAttribute('data-settingname')) === -1) { + settingElements[i].classList.add('hide'); + } else { + settingElements[i].classList.remove('hide'); } + } - initEditor(dlg, options.settings); - loadDynamicFilters(dlg, options); + initEditor(dlg, options.settings); + loadDynamicFilters(dlg, options); - bindCheckboxInput(dlg, true); - dlg.querySelector('.btnCancel').addEventListener('click', function () { - dialogHelper.close(dlg); - }); + bindCheckboxInput(dlg, true); + dlg.querySelector('.btnCancel').addEventListener('click', function () { + dialogHelper.close(dlg); + }); - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, true); - } + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, true); + } - let submitted; + let submitted; - dlg.querySelector('form').addEventListener('change', function () { - submitted = true; - }, true); + dlg.querySelector('form').addEventListener('change', function () { + submitted = true; + }, true); - dialogHelper.open(dlg).then( function() { - bindCheckboxInput(dlg, false); + dialogHelper.open(dlg).then( function() { + bindCheckboxInput(dlg, false); - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, false); - } + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, false); + } - if (submitted) { - //if (!options.onChange) { - saveValues(dlg, options.settings, options.settingsKey); - return resolve(); - //} - } + if (submitted) { + //if (!options.onChange) { + saveValues(dlg, options.settings, options.settingsKey); return resolve(); - }); + //} + } + return resolve(); }); }); } diff --git a/src/components/guide/guide-settings.js b/src/components/guide/guide-settings.js index 8132ac3bb3a..03e3c48c3a5 100644 --- a/src/components/guide/guide-settings.js +++ b/src/components/guide/guide-settings.js @@ -7,6 +7,7 @@ import '../../elements/emby-checkbox/emby-checkbox'; import '../../elements/emby-radio/emby-radio'; import '../formdialog.css'; import 'material-design-icons-iconfont'; +import template from './guide-settings.template.html'; function saveCategories(context, options) { const categories = []; @@ -88,59 +89,57 @@ function showEditor(options) { return new Promise(function (resolve, reject) { let settingsChanged = false; - import('./guide-settings.template.html').then(({ default: template }) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } - - const dlg = dialogHelper.createDialog(dialogOptions); - - dlg.classList.add('formDialog'); + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - let html = ''; + const dlg = dialogHelper.createDialog(dialogOptions); - html += globalize.translateHtml(template, 'core'); + dlg.classList.add('formDialog'); - dlg.innerHTML = html; + let html = ''; - dlg.addEventListener('change', function () { - settingsChanged = true; - }); + html += globalize.translateHtml(template, 'core'); - dlg.addEventListener('close', function () { - if (layoutManager.tv) { - scrollHelper.centerFocus.off(dlg.querySelector('.formDialogContent'), false); - } + dlg.innerHTML = html; - save(dlg); - saveCategories(dlg, options); + dlg.addEventListener('change', function () { + settingsChanged = true; + }); - if (settingsChanged) { - resolve(); - } else { - reject(); - } - }); + dlg.addEventListener('close', function () { + if (layoutManager.tv) { + scrollHelper.centerFocus.off(dlg.querySelector('.formDialogContent'), false); + } - dlg.querySelector('.btnCancel').addEventListener('click', function () { - dialogHelper.close(dlg); - }); + save(dlg); + saveCategories(dlg, options); - if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + if (settingsChanged) { + resolve(); + } else { + reject(); } + }); - load(dlg); - loadCategories(dlg, options); - dialogHelper.open(dlg); + dlg.querySelector('.btnCancel').addEventListener('click', function () { + dialogHelper.close(dlg); }); + + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + } + + load(dlg); + loadCategories(dlg, options); + dialogHelper.open(dlg); }); } diff --git a/src/components/guide/guide.js b/src/components/guide/guide.js index 7ae0b8409fc..2bc2e966b08 100644 --- a/src/components/guide/guide.js +++ b/src/components/guide/guide.js @@ -25,6 +25,7 @@ import '../../elements/emby-scroller/emby-scroller'; import '../../assets/css/flexstyles.scss'; import 'webcomponents.js/webcomponents-lite'; import ServerConnections from '../ServerConnections'; +import template from './tvguide.template.html'; function showViewSettings(instance) { import('./guide-settings').then(({default: guideSettingsDialog}) => { @@ -142,7 +143,6 @@ function Guide(options) { let autoRefreshInterval; let programCells; let lastFocusDirection; - let programGrid; self.refresh = function () { currentDate = null; @@ -772,13 +772,13 @@ function Guide(options) { let lastGridScroll = 0; let lastHeaderScroll = 0; let scrollXPct = 0; - function onProgramGridScroll(context, elem, timeslotHeaders) { + function onProgramGridScroll(context, elem, headers) { if ((new Date().getTime() - lastHeaderScroll) >= 1000) { lastGridScroll = new Date().getTime(); const scrollLeft = elem.scrollLeft; scrollXPct = (scrollLeft * 100) / elem.scrollWidth; - nativeScrollTo(timeslotHeaders, scrollLeft, true); + nativeScrollTo(headers, scrollLeft, true); } updateProgramCellsOnScroll(elem, programCells); @@ -1092,107 +1092,105 @@ function Guide(options) { } } - import('./tvguide.template.html').then(({default: template}) => { - const context = options.element; + const guideContext = options.element; - context.classList.add('tvguide'); + guideContext.classList.add('tvguide'); - context.innerHTML = globalize.translateHtml(template, 'core'); + guideContext.innerHTML = globalize.translateHtml(template, 'core'); - programGrid = context.querySelector('.programGrid'); - const timeslotHeaders = context.querySelector('.timeslotHeaders'); + const programGrid = guideContext.querySelector('.programGrid'); + const timeslotHeaders = guideContext.querySelector('.timeslotHeaders'); - if (layoutManager.tv) { - dom.addEventListener(context.querySelector('.guideVerticalScroller'), 'focus', onScrollerFocus, { - capture: true, - passive: true - }); - } else if (layoutManager.desktop) { - timeslotHeaders.classList.add('timeslotHeaders-desktop'); - } - - if (browser.iOS || browser.osx) { - context.querySelector('.channelsContainer').classList.add('noRubberBanding'); - - programGrid.classList.add('noRubberBanding'); - } - - dom.addEventListener(programGrid, 'scroll', function (e) { - onProgramGridScroll(context, this, timeslotHeaders); - }, { - passive: true - }); - - dom.addEventListener(timeslotHeaders, 'scroll', function () { - onTimeslotHeadersScroll(context, this); - }, { + if (layoutManager.tv) { + dom.addEventListener(guideContext.querySelector('.guideVerticalScroller'), 'focus', onScrollerFocus, { + capture: true, passive: true }); + } else if (layoutManager.desktop) { + timeslotHeaders.classList.add('timeslotHeaders-desktop'); + } - programGrid.addEventListener('click', onProgramGridClick); + if (browser.iOS || browser.osx) { + guideContext.querySelector('.channelsContainer').classList.add('noRubberBanding'); - context.querySelector('.btnNextPage').addEventListener('click', function () { - currentStartIndex += currentChannelLimit; - reloadPage(context); - restartAutoRefresh(); - }); + programGrid.classList.add('noRubberBanding'); + } - context.querySelector('.btnPreviousPage').addEventListener('click', function () { - currentStartIndex = Math.max(currentStartIndex - currentChannelLimit, 0); - reloadPage(context); - restartAutoRefresh(); - }); + dom.addEventListener(programGrid, 'scroll', function (e) { + onProgramGridScroll(guideContext, this, timeslotHeaders); + }, { + passive: true + }); - context.querySelector('.btnGuideViewSettings').addEventListener('click', function () { - showViewSettings(self); - restartAutoRefresh(); - }); + dom.addEventListener(timeslotHeaders, 'scroll', function () { + onTimeslotHeadersScroll(guideContext, this); + }, { + passive: true + }); - context.querySelector('.guideDateTabs').addEventListener('tabchange', function (e) { - const allTabButtons = e.target.querySelectorAll('.guide-date-tab-button'); + programGrid.addEventListener('click', onProgramGridClick); - const tabButton = allTabButtons[parseInt(e.detail.selectedTabIndex)]; - if (tabButton) { - const previousButton = e.detail.previousIndex == null ? null : allTabButtons[parseInt(e.detail.previousIndex)]; + guideContext.querySelector('.btnNextPage').addEventListener('click', function () { + currentStartIndex += currentChannelLimit; + reloadPage(guideContext); + restartAutoRefresh(); + }); - const date = new Date(); - date.setTime(parseInt(tabButton.getAttribute('data-date'))); + guideContext.querySelector('.btnPreviousPage').addEventListener('click', function () { + currentStartIndex = Math.max(currentStartIndex - currentChannelLimit, 0); + reloadPage(guideContext); + restartAutoRefresh(); + }); - const scrollWidth = programGrid.scrollWidth; - let scrollToTimeMs; - if (scrollWidth) { - scrollToTimeMs = (programGrid.scrollLeft / scrollWidth) * msPerDay; - } else { - scrollToTimeMs = 0; - } + guideContext.querySelector('.btnGuideViewSettings').addEventListener('click', function () { + showViewSettings(self); + restartAutoRefresh(); + }); - if (previousButton) { - const previousDate = new Date(); - previousDate.setTime(parseInt(previousButton.getAttribute('data-date'))); + guideContext.querySelector('.guideDateTabs').addEventListener('tabchange', function (e) { + const allTabButtons = e.target.querySelectorAll('.guide-date-tab-button'); - scrollToTimeMs += (previousDate.getHours() * 60 * 60 * 1000); - scrollToTimeMs += (previousDate.getMinutes() * 60 * 1000); - } + const tabButton = allTabButtons[parseInt(e.detail.selectedTabIndex)]; + if (tabButton) { + const previousButton = e.detail.previousIndex == null ? null : allTabButtons[parseInt(e.detail.previousIndex)]; - let startTimeOfDayMs = (date.getHours() * 60 * 60 * 1000); - startTimeOfDayMs += (date.getMinutes() * 60 * 1000); + const date = new Date(); + date.setTime(parseInt(tabButton.getAttribute('data-date'))); - changeDate(context, date, scrollToTimeMs, scrollToTimeMs, startTimeOfDayMs, false); + const scrollWidth = programGrid.scrollWidth; + let scrollToTimeMs; + if (scrollWidth) { + scrollToTimeMs = (programGrid.scrollLeft / scrollWidth) * msPerDay; + } else { + scrollToTimeMs = 0; } - }); - setScrollEvents(context, true); - itemShortcuts.on(context); + if (previousButton) { + const previousDate = new Date(); + previousDate.setTime(parseInt(previousButton.getAttribute('data-date'))); - Events.trigger(self, 'load'); + scrollToTimeMs += (previousDate.getHours() * 60 * 60 * 1000); + scrollToTimeMs += (previousDate.getMinutes() * 60 * 1000); + } - Events.on(serverNotifications, 'TimerCreated', onTimerCreated); - Events.on(serverNotifications, 'SeriesTimerCreated', onSeriesTimerCreated); - Events.on(serverNotifications, 'TimerCancelled', onTimerCancelled); - Events.on(serverNotifications, 'SeriesTimerCancelled', onSeriesTimerCancelled); + let startTimeOfDayMs = (date.getHours() * 60 * 60 * 1000); + startTimeOfDayMs += (date.getMinutes() * 60 * 1000); - self.refresh(); + changeDate(guideContext, date, scrollToTimeMs, scrollToTimeMs, startTimeOfDayMs, false); + } }); + + setScrollEvents(guideContext, true); + itemShortcuts.on(guideContext); + + Events.trigger(self, 'load'); + + Events.on(serverNotifications, 'TimerCreated', onTimerCreated); + Events.on(serverNotifications, 'SeriesTimerCreated', onSeriesTimerCreated); + Events.on(serverNotifications, 'TimerCancelled', onTimerCancelled); + Events.on(serverNotifications, 'SeriesTimerCancelled', onSeriesTimerCancelled); + + self.refresh(); } export default Guide; diff --git a/src/components/homeScreenSettings/homeScreenSettings.js b/src/components/homeScreenSettings/homeScreenSettings.js index 89edf29486a..b07203442f9 100644 --- a/src/components/homeScreenSettings/homeScreenSettings.js +++ b/src/components/homeScreenSettings/homeScreenSettings.js @@ -11,6 +11,7 @@ import '../../elements/emby-select/emby-select'; import '../../elements/emby-checkbox/emby-checkbox'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; +import template from './homeScreenSettings.template.html'; /* eslint-disable indent */ @@ -56,8 +57,8 @@ import toast from '../toast/toast'; value: 'suggestions' }); list.push({ - name: globalize.translate('Genres'), - value: 'genres' + name: globalize.translate('Trailers'), + value: 'trailers' }); list.push({ name: globalize.translate('Favorites'), @@ -67,6 +68,10 @@ import toast from '../toast/toast'; name: globalize.translate('Collections'), value: 'collections' }); + list.push({ + name: globalize.translate('Genres'), + value: 'genres' + }); } else if (type === 'tvshows') { list.push({ name: globalize.translate('Shows'), @@ -78,7 +83,7 @@ import toast from '../toast/toast'; value: 'suggestions' }); list.push({ - name: globalize.translate('Upcoming'), + name: globalize.translate('TabUpcoming'), value: 'upcoming' }); list.push({ @@ -86,7 +91,7 @@ import toast from '../toast/toast'; value: 'genres' }); list.push({ - name: globalize.translate('Networks'), + name: globalize.translate('TabNetworks'), value: 'networks' }); list.push({ @@ -115,20 +120,40 @@ import toast from '../toast/toast'; name: globalize.translate('Playlists'), value: 'playlists' }); + list.push({ + name: globalize.translate('Songs'), + value: 'songs' + }); list.push({ name: globalize.translate('Genres'), value: 'genres' }); } else if (type === 'livetv') { list.push({ - name: globalize.translate('Suggestions'), - value: 'suggestions', + name: globalize.translate('Programs'), + value: 'programs', isDefault: true }); list.push({ name: globalize.translate('Guide'), value: 'guide' }); + list.push({ + name: globalize.translate('Channels'), + value: 'channels' + }); + list.push({ + name: globalize.translate('Recordings'), + value: 'recordings' + }); + list.push({ + name: globalize.translate('Schedule'), + value: 'schedule' + }); + list.push({ + name: globalize.translate('Series'), + value: 'series' + }); } return list; @@ -418,29 +443,28 @@ import toast from '../toast/toast'; } function embed(options, self) { - return import('./homeScreenSettings.template.html').then(({default: template}) => { - for (let i = 1; i <= numConfigurableSections; i++) { - template = template.replace(`{section${i}label}`, globalize.translate('LabelHomeScreenSectionValue', i)); - } + let workingTemplate = template; + for (let i = 1; i <= numConfigurableSections; i++) { + workingTemplate = workingTemplate.replace(`{section${i}label}`, globalize.translate('LabelHomeScreenSectionValue', i)); + } - options.element.innerHTML = globalize.translateHtml(template, 'core'); + options.element.innerHTML = globalize.translateHtml(workingTemplate, 'core'); - options.element.querySelector('.viewOrderList').addEventListener('click', onSectionOrderListClick); - options.element.querySelector('form').addEventListener('submit', onSubmit.bind(self)); - options.element.addEventListener('change', onChange); + options.element.querySelector('.viewOrderList').addEventListener('click', onSectionOrderListClick); + options.element.querySelector('form').addEventListener('submit', onSubmit.bind(self)); + options.element.addEventListener('change', onChange); - if (options.enableSaveButton) { - options.element.querySelector('.btnSave').classList.remove('hide'); - } + if (options.enableSaveButton) { + options.element.querySelector('.btnSave').classList.remove('hide'); + } - if (layoutManager.tv) { - options.element.querySelector('.selectTVHomeScreenContainer').classList.remove('hide'); - } else { - options.element.querySelector('.selectTVHomeScreenContainer').classList.add('hide'); - } + if (layoutManager.tv) { + options.element.querySelector('.selectTVHomeScreenContainer').classList.remove('hide'); + } else { + options.element.querySelector('.selectTVHomeScreenContainer').classList.add('hide'); + } - self.loadData(options.autoFocus); - }); + self.loadData(options.autoFocus); } class HomeScreenSettings { diff --git a/src/components/homesections/homesections.js b/src/components/homesections/homesections.js index 6561b8a9e06..f6fb8ec7bb9 100644 --- a/src/components/homesections/homesections.js +++ b/src/components/homesections/homesections.js @@ -661,7 +661,7 @@ import ServerConnections from '../ServerConnections'; const apiClient = ServerConnections.getApiClient(serverId); return apiClient.getNextUpEpisodes({ Limit: enableScrollX() ? 24 : 15, - Fields: 'PrimaryImageAspectRatio,SeriesInfo,DateCreated,BasicSyncInfo,Path', + Fields: 'PrimaryImageAspectRatio,DateCreated,BasicSyncInfo,Path', UserId: apiClient.getCurrentUserId(), ImageTypeLimit: 1, EnableImageTypes: 'Primary,Backdrop,Banner,Thumb', diff --git a/src/components/htmlMediaHelper.js b/src/components/htmlMediaHelper.js index 4f803aa4b61..da53d6a063e 100644 --- a/src/components/htmlMediaHelper.js +++ b/src/components/htmlMediaHelper.js @@ -158,15 +158,11 @@ import { Events } from 'jellyfin-apiclient'; // (but rewinding cannot happen as the first event with media of non-empty duration) console.debug(`seeking to ${seconds} on ${e.type} event`); setCurrentTimeIfNeeded(element, seconds); - events.map(function(name) { - element.removeEventListener(name, onMediaChange); - }); + events.forEach(name => element.removeEventListener(name, onMediaChange)); if (onMediaReady) onMediaReady(); } }; - events.map(function (name) { - return element.addEventListener(name, onMediaChange); - }); + events.forEach(name => element.addEventListener(name, onMediaChange)); } } } diff --git a/src/components/imageDownloader/imageDownloader.js b/src/components/imageDownloader/imageDownloader.js index 18dd849aecb..e501a808c9b 100644 --- a/src/components/imageDownloader/imageDownloader.js +++ b/src/components/imageDownloader/imageDownloader.js @@ -13,6 +13,7 @@ import '../../elements/emby-button/emby-button'; import '../formdialog.css'; import '../cardbuilder/card.css'; import ServerConnections from '../ServerConnections'; +import template from './imageDownloader.template.html'; /* eslint-disable indent */ @@ -316,44 +317,42 @@ import ServerConnections from '../ServerConnections'; function showEditor(itemId, serverId, itemType) { loading.show(); - import('./imageDownloader.template.html').then(({default: template}) => { - const apiClient = ServerConnections.getApiClient(serverId); + const apiClient = ServerConnections.getApiClient(serverId); - currentItemId = itemId; - currentItemType = itemType; + currentItemId = itemId; + currentItemType = itemType; - const dialogOptions = { - removeOnClose: true - }; + const dialogOptions = { + removeOnClose: true + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } - - const dlg = dialogHelper.createDialog(dialogOptions); + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - dlg.innerHTML = globalize.translateHtml(template, 'core'); + const dlg = dialogHelper.createDialog(dialogOptions); - if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg, false); - } + dlg.innerHTML = globalize.translateHtml(template, 'core'); - // Has to be assigned a z-index after the call to .open() - dlg.addEventListener('close', onDialogClosed); + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg, false); + } - dialogHelper.open(dlg); + // Has to be assigned a z-index after the call to .open() + dlg.addEventListener('close', onDialogClosed); - const editorContent = dlg.querySelector('.formDialogContent'); - initEditor(editorContent, apiClient); + dialogHelper.open(dlg); - dlg.querySelector('.btnCancel').addEventListener('click', function () { - dialogHelper.close(dlg); - }); + const editorContent = dlg.querySelector('.formDialogContent'); + initEditor(editorContent, apiClient); - reloadBrowsableImages(editorContent, apiClient); + dlg.querySelector('.btnCancel').addEventListener('click', function () { + dialogHelper.close(dlg); }); + + reloadBrowsableImages(editorContent, apiClient); } function onDialogClosed() { diff --git a/src/components/imageOptionsEditor/imageOptionsEditor.js b/src/components/imageOptionsEditor/imageOptionsEditor.js index a220a65c5dd..fb3a72b853a 100644 --- a/src/components/imageOptionsEditor/imageOptionsEditor.js +++ b/src/components/imageOptionsEditor/imageOptionsEditor.js @@ -11,6 +11,7 @@ import dialogHelper from '../dialogHelper/dialogHelper'; import '../../elements/emby-checkbox/emby-checkbox'; import '../../elements/emby-select/emby-select'; import '../../elements/emby-input/emby-input'; +import template from './imageOptionsEditor.template.html'; function getDefaultImageConfig(itemType, type) { return { @@ -89,10 +90,7 @@ import '../../elements/emby-input/emby-input'; }); } - async function showEditor(itemType, options, availableOptions) { - const response = await fetch('components/imageOptionsEditor/imageOptionsEditor.template.html'); - const template = await response.text(); - + function showEditor(itemType, options, availableOptions) { const dlg = dialogHelper.createDialog({ size: 'small', removeOnClose: true, diff --git a/src/components/imageUploader/imageUploader.js b/src/components/imageUploader/imageUploader.js index 10beff5cab6..15a2d5cbbc7 100644 --- a/src/components/imageUploader/imageUploader.js +++ b/src/components/imageUploader/imageUploader.js @@ -17,6 +17,7 @@ import '../formdialog.css'; import './style.css'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; +import template from './imageUploader.template.html'; let currentItemId; let currentServerId; @@ -128,49 +129,47 @@ import toast from '../toast/toast'; function showEditor(options, resolve) { options = options || {}; - return import('./imageUploader.template.html').then(({default: template}) => { - currentItemId = options.itemId; - currentServerId = options.serverId; + currentItemId = options.itemId; + currentServerId = options.serverId; - const dialogOptions = { - removeOnClose: true - }; + const dialogOptions = { + removeOnClose: true + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); + dlg.classList.add('formDialog'); - dlg.innerHTML = globalize.translateHtml(template, 'core'); + dlg.innerHTML = globalize.translateHtml(template, 'core'); + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg, false); + } + + // Has to be assigned a z-index after the call to .open() + dlg.addEventListener('close', () => { if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg, false); + scrollHelper.centerFocus.off(dlg, false); } - // Has to be assigned a z-index after the call to .open() - dlg.addEventListener('close', () => { - if (layoutManager.tv) { - scrollHelper.centerFocus.off(dlg, false); - } - - loading.hide(); - resolve(hasChanges); - }); + loading.hide(); + resolve(hasChanges); + }); - dialogHelper.open(dlg); + dialogHelper.open(dlg); - initEditor(dlg); + initEditor(dlg); - dlg.querySelector('#selectImageType').value = options.imageType || 'Primary'; + dlg.querySelector('#selectImageType').value = options.imageType || 'Primary'; - dlg.querySelector('.btnCancel').addEventListener('click', () => { - dialogHelper.close(dlg); - }); + dlg.querySelector('.btnCancel').addEventListener('click', () => { + dialogHelper.close(dlg); }); } diff --git a/src/components/imageeditor/imageeditor.js b/src/components/imageeditor/imageeditor.js index 69e9bcb8c74..60fbddbaad8 100644 --- a/src/components/imageeditor/imageeditor.js +++ b/src/components/imageeditor/imageeditor.js @@ -16,6 +16,7 @@ import './imageeditor.css'; import ServerConnections from '../ServerConnections'; import alert from '../alert'; import confirm from '../confirm/confirm'; +import template from './imageeditor.template.html'; /* eslint-disable indent */ @@ -419,53 +420,51 @@ import confirm from '../confirm/confirm'; loading.show(); - import('./imageeditor.template.html').then(({default: template}) => { - const apiClient = ServerConnections.getApiClient(serverId); - apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) { - const dialogOptions = { - removeOnClose: true - }; + const apiClient = ServerConnections.getApiClient(serverId); + apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) { + const dialogOptions = { + removeOnClose: true + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); + dlg.classList.add('formDialog'); - dlg.innerHTML = globalize.translateHtml(template, 'core'); + dlg.innerHTML = globalize.translateHtml(template, 'core'); - if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg, false); - } + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg, false); + } - initEditor(dlg, options); + initEditor(dlg, options); - // Has to be assigned a z-index after the call to .open() - dlg.addEventListener('close', function () { - if (layoutManager.tv) { - scrollHelper.centerFocus.off(dlg, false); - } + // Has to be assigned a z-index after the call to .open() + dlg.addEventListener('close', function () { + if (layoutManager.tv) { + scrollHelper.centerFocus.off(dlg, false); + } - loading.hide(); + loading.hide(); - if (hasChanges) { - resolve(); - } else { - reject(); - } - }); + if (hasChanges) { + resolve(); + } else { + reject(); + } + }); - dialogHelper.open(dlg); + dialogHelper.open(dlg); - reload(dlg, item); + reload(dlg, item); - dlg.querySelector('.btnCancel').addEventListener('click', function () { - dialogHelper.close(dlg); - }); + dlg.querySelector('.btnCancel').addEventListener('click', function () { + dialogHelper.close(dlg); }); }); } diff --git a/src/components/itemMediaInfo/itemMediaInfo.js b/src/components/itemMediaInfo/itemMediaInfo.js index 2094bcdacc8..37e28ec4d58 100644 --- a/src/components/itemMediaInfo/itemMediaInfo.js +++ b/src/components/itemMediaInfo/itemMediaInfo.js @@ -17,6 +17,7 @@ import '../formdialog.css'; import 'material-design-icons-iconfont'; import '../../assets/css/flexstyles.scss'; import ServerConnections from '../ServerConnections'; +import template from './itemMediaInfo.template.html'; function setMediaInfo(user, page, item) { let html = item.MediaSources.map(version => { @@ -162,7 +163,7 @@ import ServerConnections from '../ServerConnections'; return `${label}${value}`; } - function loadMediaInfo(itemId, serverId, template) { + function loadMediaInfo(itemId, serverId) { const apiClient = ServerConnections.getApiClient(serverId); return apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(item => { const dialogOptions = { @@ -194,11 +195,7 @@ import ServerConnections from '../ServerConnections'; export function show(itemId, serverId) { loading.show(); - return import('./itemMediaInfo.template.html').then(({default: template}) => { - return new Promise((resolve, reject) => { - loadMediaInfo(itemId, serverId, template).then(resolve, reject); - }); - }); + return loadMediaInfo(itemId, serverId); } /* eslint-enable indent */ diff --git a/src/components/itemidentifier/itemidentifier.js b/src/components/itemidentifier/itemidentifier.js index 382226478fa..227813b1172 100644 --- a/src/components/itemidentifier/itemidentifier.js +++ b/src/components/itemidentifier/itemidentifier.js @@ -20,6 +20,7 @@ import 'material-design-icons-iconfont'; import '../cardbuilder/card.css'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; +import template from './itemidentifier.template.html'; const enableFocusTransform = !browser.slow && !browser.edge; @@ -260,7 +261,11 @@ import toast from '../toast/toast'; function getSearchImageDisplayUrl(url, provider) { const apiClient = getApiClient(); - return apiClient.getUrl('Items/RemoteSearch/Image', { imageUrl: url, ProviderName: provider }); + return apiClient.getUrl('Items/RemoteSearch/Image', { + imageUrl: url, + ProviderName: provider, + api_key: apiClient.accessToken() + }); } function submitIdentficationResult(page) { @@ -334,71 +339,69 @@ import toast from '../toast/toast'; function showEditor(itemId) { loading.show(); - return import('./itemidentifier.template.html').then(({default: template}) => { - const apiClient = getApiClient(); + const apiClient = getApiClient(); - apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(item => { - currentItem = item; - currentItemType = currentItem.Type; + apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(item => { + currentItem = item; + currentItemType = currentItem.Type; - const dialogOptions = { - size: 'small', - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + size: 'small', + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); - dlg.classList.add('recordingDialog'); + dlg.classList.add('formDialog'); + dlg.classList.add('recordingDialog'); - let html = ''; - html += globalize.translateHtml(template, 'core'); + let html = ''; + html += globalize.translateHtml(template, 'core'); - dlg.innerHTML = html; + dlg.innerHTML = html; - // Has to be assigned a z-index after the call to .open() - dlg.addEventListener('close', onDialogClosed); + // Has to be assigned a z-index after the call to .open() + dlg.addEventListener('close', onDialogClosed); - if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); - } + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + } - if (item.Path) { - dlg.querySelector('.fldPath').classList.remove('hide'); - } else { - dlg.querySelector('.fldPath').classList.add('hide'); - } + if (item.Path) { + dlg.querySelector('.fldPath').classList.remove('hide'); + } else { + dlg.querySelector('.fldPath').classList.add('hide'); + } - dlg.querySelector('.txtPath').innerHTML = item.Path || ''; + dlg.querySelector('.txtPath').innerHTML = item.Path || ''; - dialogHelper.open(dlg); + dialogHelper.open(dlg); - dlg.querySelector('.popupIdentifyForm').addEventListener('submit', e => { - e.preventDefault(); - searchForIdentificationResults(dlg); - return false; - }); + dlg.querySelector('.popupIdentifyForm').addEventListener('submit', e => { + e.preventDefault(); + searchForIdentificationResults(dlg); + return false; + }); - dlg.querySelector('.identifyOptionsForm').addEventListener('submit', e => { - e.preventDefault(); - submitIdentficationResult(dlg); - return false; - }); + dlg.querySelector('.identifyOptionsForm').addEventListener('submit', e => { + e.preventDefault(); + submitIdentficationResult(dlg); + return false; + }); - dlg.querySelector('.btnCancel').addEventListener('click', () => { - dialogHelper.close(dlg); - }); + dlg.querySelector('.btnCancel').addEventListener('click', () => { + dialogHelper.close(dlg); + }); - dlg.classList.add('identifyDialog'); + dlg.classList.add('identifyDialog'); - showIdentificationForm(dlg, item); - loading.hide(); - }); + showIdentificationForm(dlg, item); + loading.hide(); }); } @@ -416,54 +419,52 @@ import toast from '../toast/toast'; currentItem = null; currentItemType = itemType; - return import('./itemidentifier.template.html').then(({default: template}) => { - const dialogOptions = { - size: 'small', - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + size: 'small', + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); - dlg.classList.add('recordingDialog'); + dlg.classList.add('formDialog'); + dlg.classList.add('recordingDialog'); - let html = ''; - html += globalize.translateHtml(template, 'core'); + let html = ''; + html += globalize.translateHtml(template, 'core'); - dlg.innerHTML = html; + dlg.innerHTML = html; - if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); - } + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + } - dialogHelper.open(dlg); + dialogHelper.open(dlg); - dlg.querySelector('.btnCancel').addEventListener('click', () => { - dialogHelper.close(dlg); - }); + dlg.querySelector('.btnCancel').addEventListener('click', () => { + dialogHelper.close(dlg); + }); - dlg.querySelector('.popupIdentifyForm').addEventListener('submit', e => { - e.preventDefault(); - searchForIdentificationResults(dlg); - return false; - }); + dlg.querySelector('.popupIdentifyForm').addEventListener('submit', e => { + e.preventDefault(); + searchForIdentificationResults(dlg); + return false; + }); - dlg.addEventListener('close', () => { - loading.hide(); - const foundItem = hasChanges ? currentSearchResult : null; + dlg.addEventListener('close', () => { + loading.hide(); + const foundItem = hasChanges ? currentSearchResult : null; - resolveFunc(foundItem); - }); + resolveFunc(foundItem); + }); - dlg.classList.add('identifyDialog'); + dlg.classList.add('identifyDialog'); - showIdentificationFormFindNew(dlg, itemName, itemYear, itemType); - }); + showIdentificationFormFindNew(dlg, itemName, itemYear, itemType); } function showIdentificationFormFindNew(dlg, itemName, itemYear, itemType) { diff --git a/src/components/libraryoptionseditor/libraryoptionseditor.js b/src/components/libraryoptionseditor/libraryoptionseditor.js index 9b20cfff25c..91a45a7f3a6 100644 --- a/src/components/libraryoptionseditor/libraryoptionseditor.js +++ b/src/components/libraryoptionseditor/libraryoptionseditor.js @@ -10,6 +10,7 @@ import dom from '../../scripts/dom'; import '../../elements/emby-checkbox/emby-checkbox'; import '../../elements/emby-select/emby-select'; import '../../elements/emby-input/emby-input'; +import template from './libraryoptionseditor.template.html'; function populateLanguages(parent) { return ApiClient.getCultures().then(languages => { @@ -363,8 +364,6 @@ import '../../elements/emby-input/emby-input'; const isNewLibrary = libraryOptions === null; isNewLibrary && parent.classList.add('newlibrary'); - const { default: template } = await import('./libraryoptionseditor.template.html'); - parent.innerHTML = globalize.translateHtml(template); populateRefreshInterval(parent.querySelector('#selectAutoRefreshInterval')); const promises = [populateLanguages(parent), populateCountries(parent.querySelector('#selectCountry'))]; diff --git a/src/components/mediaLibraryCreator/mediaLibraryCreator.js b/src/components/mediaLibraryCreator/mediaLibraryCreator.js index 60803945dc6..c50a5ab25aa 100644 --- a/src/components/mediaLibraryCreator/mediaLibraryCreator.js +++ b/src/components/mediaLibraryCreator/mediaLibraryCreator.js @@ -21,6 +21,7 @@ import '../formdialog.css'; import '../../assets/css/flexstyles.scss'; import toast from '../toast/toast'; import alert from '../alert'; +import template from './mediaLibraryCreator.template.html'; function onAddLibrary() { if (isCreating) { @@ -191,28 +192,26 @@ export class showEditor { currentOptions = options; currentResolve = resolve; hasChanges = false; - import('./mediaLibraryCreator.template.html').then(({default: template}) => { - const dlg = dialogHelper.createDialog({ - size: 'small', - modal: false, - removeOnClose: true, - scrollY: false - }); - dlg.classList.add('ui-body-a'); - dlg.classList.add('background-theme-a'); - dlg.classList.add('dlg-librarycreator'); - dlg.classList.add('formDialog'); - dlg.innerHTML = globalize.translateHtml(template); - initEditor(dlg, options.collectionTypeOptions); - dlg.addEventListener('close', onDialogClosed); - dialogHelper.open(dlg); - dlg.querySelector('.btnCancel').addEventListener('click', () => { - dialogHelper.close(dlg); - }); - pathInfos = []; - renderPaths(dlg); - initLibraryOptions(dlg); + const dlg = dialogHelper.createDialog({ + size: 'small', + modal: false, + removeOnClose: true, + scrollY: false }); + dlg.classList.add('ui-body-a'); + dlg.classList.add('background-theme-a'); + dlg.classList.add('dlg-librarycreator'); + dlg.classList.add('formDialog'); + dlg.innerHTML = globalize.translateHtml(template); + initEditor(dlg, options.collectionTypeOptions); + dlg.addEventListener('close', onDialogClosed); + dialogHelper.open(dlg); + dlg.querySelector('.btnCancel').addEventListener('click', () => { + dialogHelper.close(dlg); + }); + pathInfos = []; + renderPaths(dlg); + initLibraryOptions(dlg); }); } } diff --git a/src/components/mediaLibraryEditor/mediaLibraryEditor.js b/src/components/mediaLibraryEditor/mediaLibraryEditor.js index 1cb6e26ac7b..dede7afa77d 100644 --- a/src/components/mediaLibraryEditor/mediaLibraryEditor.js +++ b/src/components/mediaLibraryEditor/mediaLibraryEditor.js @@ -19,6 +19,7 @@ import '../../elements/emby-toggle/emby-toggle'; import '../../assets/css/flexstyles.scss'; import toast from '../toast/toast'; import confirm from '../confirm/confirm'; +import template from './mediaLibraryEditor.template.html'; function onEditLibrary() { if (isCreating) { @@ -201,27 +202,25 @@ export class showEditor { currentOptions = options; currentDeferred = deferred; hasChanges = false; - import('./mediaLibraryEditor.template.html').then(({default: template}) => { - const dlg = dialogHelper.createDialog({ - size: 'small', - modal: false, - removeOnClose: true, - scrollY: false - }); - dlg.classList.add('dlg-libraryeditor'); - dlg.classList.add('ui-body-a'); - dlg.classList.add('background-theme-a'); - dlg.classList.add('formDialog'); - dlg.innerHTML = globalize.translateHtml(template); - dlg.querySelector('.formDialogHeaderTitle').innerHTML = options.library.Name; - initEditor(dlg, options); - dlg.addEventListener('close', onDialogClosed); - dialogHelper.open(dlg); - dlg.querySelector('.btnCancel').addEventListener('click', () => { - dialogHelper.close(dlg); - }); - refreshLibraryFromServer(dlg); + const dlg = dialogHelper.createDialog({ + size: 'small', + modal: false, + removeOnClose: true, + scrollY: false + }); + dlg.classList.add('dlg-libraryeditor'); + dlg.classList.add('ui-body-a'); + dlg.classList.add('background-theme-a'); + dlg.classList.add('formDialog'); + dlg.innerHTML = globalize.translateHtml(template); + dlg.querySelector('.formDialogHeaderTitle').innerHTML = options.library.Name; + initEditor(dlg, options); + dlg.addEventListener('close', onDialogClosed); + dialogHelper.open(dlg); + dlg.querySelector('.btnCancel').addEventListener('click', () => { + dialogHelper.close(dlg); }); + refreshLibraryFromServer(dlg); return deferred.promise(); } } diff --git a/src/components/metadataEditor/metadataEditor.js b/src/components/metadataEditor/metadataEditor.js index 71bf1bc00b6..ff1654a3c58 100644 --- a/src/components/metadataEditor/metadataEditor.js +++ b/src/components/metadataEditor/metadataEditor.js @@ -19,6 +19,7 @@ import '../../assets/css/flexstyles.scss'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; import { appRouter } from '../appRouter'; +import template from './metadataEditor.template.html'; /* eslint-disable indent */ @@ -1028,48 +1029,46 @@ import { appRouter } from '../appRouter'; function show(itemId, serverId, resolve, reject) { loading.show(); - import('./metadataEditor.template.html').then(({default: template}) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); + dlg.classList.add('formDialog'); - let html = ''; + let html = ''; - html += globalize.translateHtml(template, 'core'); + html += globalize.translateHtml(template, 'core'); - dlg.innerHTML = html; + dlg.innerHTML = html; - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, true); - } + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, true); + } - dialogHelper.open(dlg); + dialogHelper.open(dlg); - dlg.addEventListener('close', function () { - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, false); - } + dlg.addEventListener('close', function () { + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, false); + } - resolve(); - }); + resolve(); + }); - currentContext = dlg; + currentContext = dlg; - init(dlg, ServerConnections.getApiClient(serverId)); + init(dlg, ServerConnections.getApiClient(serverId)); - reload(dlg, itemId, serverId); - }); + reload(dlg, itemId, serverId); } export default { @@ -1083,21 +1082,19 @@ import { appRouter } from '../appRouter'; return new Promise(function (resolve, reject) { loading.show(); - import('./metadataEditor.template.html').then(({default: template}) => { - elem.innerHTML = globalize.translateHtml(template, 'core'); + elem.innerHTML = globalize.translateHtml(template, 'core'); - elem.querySelector('.formDialogFooter').classList.remove('formDialogFooter'); - elem.querySelector('.btnClose').classList.add('hide'); - elem.querySelector('.btnHeaderSave').classList.remove('hide'); - elem.querySelector('.btnCancel').classList.add('hide'); + elem.querySelector('.formDialogFooter').classList.remove('formDialogFooter'); + elem.querySelector('.btnClose').classList.add('hide'); + elem.querySelector('.btnHeaderSave').classList.remove('hide'); + elem.querySelector('.btnCancel').classList.add('hide'); - currentContext = elem; + currentContext = elem; - init(elem, ServerConnections.getApiClient(serverId)); - reload(elem, itemId, serverId); + init(elem, ServerConnections.getApiClient(serverId)); + reload(elem, itemId, serverId); - focusManager.autoFocus(elem); - }); + focusManager.autoFocus(elem); }); } }; diff --git a/src/components/metadataEditor/personEditor.js b/src/components/metadataEditor/personEditor.js index f64f7330d79..5c3b406bd6d 100644 --- a/src/components/metadataEditor/personEditor.js +++ b/src/components/metadataEditor/personEditor.js @@ -6,6 +6,7 @@ import '../../elements/emby-button/paper-icon-button-light'; import '../../elements/emby-input/emby-input'; import '../../elements/emby-select/emby-select'; import '../formdialog.css'; +import template from './personEditor.template.html'; /* eslint-disable indent */ @@ -18,80 +19,78 @@ import '../formdialog.css'; function show(person) { return new Promise(function (resolve, reject) { - import('./personEditor.template.html').then(({default: template}) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } - - const dlg = dialogHelper.createDialog(dialogOptions); + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - dlg.classList.add('formDialog'); + const dlg = dialogHelper.createDialog(dialogOptions); - let html = ''; - let submitted = false; + dlg.classList.add('formDialog'); - html += globalize.translateHtml(template, 'core'); + let html = ''; + let submitted = false; - dlg.innerHTML = html; + html += globalize.translateHtml(template, 'core'); - dlg.querySelector('.txtPersonName', dlg).value = person.Name || ''; - dlg.querySelector('.selectPersonType', dlg).value = person.Type || ''; - dlg.querySelector('.txtPersonRole', dlg).value = person.Role || ''; + dlg.innerHTML = html; - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, true); - } + dlg.querySelector('.txtPersonName', dlg).value = person.Name || ''; + dlg.querySelector('.selectPersonType', dlg).value = person.Type || ''; + dlg.querySelector('.txtPersonRole', dlg).value = person.Role || ''; - dialogHelper.open(dlg); + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, true); + } - dlg.addEventListener('close', function () { - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, false); - } + dialogHelper.open(dlg); - if (submitted) { - resolve(person); - } else { - reject(); - } - }); + dlg.addEventListener('close', function () { + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, false); + } - dlg.querySelector('.selectPersonType').addEventListener('change', function (e) { - if (this.value === 'Actor') { - dlg.querySelector('.fldRole').classList.remove('hide'); - } else { - dlg.querySelector('.fldRole').classList.add('hide'); - } - }); + if (submitted) { + resolve(person); + } else { + reject(); + } + }); - dlg.querySelector('.btnCancel').addEventListener('click', function (e) { - dialogHelper.close(dlg); - }); + dlg.querySelector('.selectPersonType').addEventListener('change', function (e) { + if (this.value === 'Actor') { + dlg.querySelector('.fldRole').classList.remove('hide'); + } else { + dlg.querySelector('.fldRole').classList.add('hide'); + } + }); - dlg.querySelector('form').addEventListener('submit', function (e) { - submitted = true; + dlg.querySelector('.btnCancel').addEventListener('click', function (e) { + dialogHelper.close(dlg); + }); - person.Name = dlg.querySelector('.txtPersonName', dlg).value; - person.Type = dlg.querySelector('.selectPersonType', dlg).value; - person.Role = dlg.querySelector('.txtPersonRole', dlg).value || null; + dlg.querySelector('form').addEventListener('submit', function (e) { + submitted = true; - dialogHelper.close(dlg); + person.Name = dlg.querySelector('.txtPersonName', dlg).value; + person.Type = dlg.querySelector('.selectPersonType', dlg).value; + person.Role = dlg.querySelector('.txtPersonRole', dlg).value || null; - e.preventDefault(); - return false; - }); + dialogHelper.close(dlg); - dlg.querySelector('.selectPersonType').dispatchEvent(new CustomEvent('change', { - bubbles: true - })); + e.preventDefault(); + return false; }); + + dlg.querySelector('.selectPersonType').dispatchEvent(new CustomEvent('change', { + bubbles: true + })); }); } diff --git a/src/components/nowPlayingBar/nowPlayingBar.js b/src/components/nowPlayingBar/nowPlayingBar.js index be8895ca6a1..2613b5a8558 100644 --- a/src/components/nowPlayingBar/nowPlayingBar.js +++ b/src/components/nowPlayingBar/nowPlayingBar.js @@ -660,7 +660,7 @@ import { appRouter } from '../appRouter'; console.debug('nowplaying event: ' + event.type); const player = this; - if (!state.NowPlayingItem || layoutManager.tv || !state.IsFullscreen) { + if (!state.NowPlayingItem || layoutManager.tv || state.IsFullscreen === false) { hideNowPlayingBar(); return; } diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index a2e07fe8fa9..6d9aebdacbb 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -1876,6 +1876,9 @@ class PlaybackManager { } } + self.translateItemsForPlayback = translateItemsForPlayback; + self.getItemsForPlayback = getItemsForPlayback; + self.play = function (options) { normalizePlayOptions(options); @@ -2504,29 +2507,38 @@ class PlaybackManager { })[0]; } - self.setCurrentPlaylistItem = function (playlistItemId, player) { - player = player || self._currentPlayer; - if (player && !enableLocalPlaylistManagement(player)) { - return player.setCurrentPlaylistItem(playlistItemId); - } - - let newItem; - let newItemIndex; + self.getItemFromPlaylistItemId = function (playlistItemId) { + let item; + let itemIndex; const playlist = self._playQueueManager.getPlaylist(); for (let i = 0, length = playlist.length; i < length; i++) { if (playlist[i].PlaylistItemId === playlistItemId) { - newItem = playlist[i]; - newItemIndex = i; + item = playlist[i]; + itemIndex = i; break; } } - if (newItem) { - const newItemPlayOptions = newItem.playOptions || getDefaultPlayOptions(); + return { + Item: item, + Index: itemIndex + }; + }; - playInternal(newItem, newItemPlayOptions, function () { - setPlaylistState(newItem.PlaylistItemId, newItemIndex); + self.setCurrentPlaylistItem = function (playlistItemId, player) { + player = player || self._currentPlayer; + if (player && !enableLocalPlaylistManagement(player)) { + return player.setCurrentPlaylistItem(playlistItemId); + } + + const newItem = self.getItemFromPlaylistItemId(playlistItemId); + + if (newItem.Item) { + const newItemPlayOptions = newItem.Item.playOptions || getDefaultPlayOptions(); + + playInternal(newItem.Item, newItemPlayOptions, function () { + setPlaylistState(newItem.Item.PlaylistItemId, newItem.Index); }); } }; @@ -2905,6 +2917,8 @@ class PlaybackManager { } } + Events.trigger(self, 'playbackerror', [errorType]); + const displayErrorCode = 'NoCompatibleStream'; onPlaybackStopped.call(player, e, displayErrorCode); } diff --git a/src/components/playbackSettings/playbackSettings.js b/src/components/playbackSettings/playbackSettings.js index 6e6fa736478..0e52ae5a357 100644 --- a/src/components/playbackSettings/playbackSettings.js +++ b/src/components/playbackSettings/playbackSettings.js @@ -10,6 +10,7 @@ import '../../elements/emby-select/emby-select'; import '../../elements/emby-checkbox/emby-checkbox'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; +import template from './playbackSettings.template.html'; /* eslint-disable indent */ @@ -278,21 +279,19 @@ import toast from '../toast/toast'; } function embed(options, self) { - return import('./playbackSettings.template.html').then(({default: template}) => { - options.element.innerHTML = globalize.translateHtml(template, 'core'); + options.element.innerHTML = globalize.translateHtml(template, 'core'); - options.element.querySelector('form').addEventListener('submit', onSubmit.bind(self)); + options.element.querySelector('form').addEventListener('submit', onSubmit.bind(self)); - if (options.enableSaveButton) { - options.element.querySelector('.btnSave').classList.remove('hide'); - } + if (options.enableSaveButton) { + options.element.querySelector('.btnSave').classList.remove('hide'); + } - self.loadData(); + self.loadData(); - if (options.autoFocus) { - focusManager.autoFocus(options.element); - } - }); + if (options.autoFocus) { + focusManager.autoFocus(options.element); + } } class PlaybackSettings { diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index 8cad31c65e2..106ca0a7cda 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -4,7 +4,7 @@ import globalize from '../../scripts/globalize'; import layoutManager from '../layoutManager'; import { playbackManager } from '../playback/playbackmanager'; import playMethodHelper from '../playback/playmethodhelper'; -import syncPlayManager from '../syncPlay/syncPlayManager'; +import SyncPlay from '../../components/syncPlay/core'; import './playerstats.css'; import ServerConnections from '../ServerConnections'; @@ -342,16 +342,22 @@ import ServerConnections from '../ServerConnections'; function getSyncPlayStats() { const syncStats = []; - const stats = syncPlayManager.getStats(); + const stats = SyncPlay.Manager.getStats(); syncStats.push({ - label: globalize.translate('LabelSyncPlayTimeOffset'), - value: stats.TimeOffset + globalize.translate('MillisecondsUnit') + label: globalize.translate('LabelSyncPlayTimeSyncDevice'), + value: stats.TimeSyncDevice + }); + + syncStats.push({ + // TODO: clean old string 'LabelSyncPlayTimeOffset' from translations. + label: globalize.translate('LabelSyncPlayTimeSyncOffset'), + value: stats.TimeSyncOffset + ' ' + globalize.translate('MillisecondsUnit') }); syncStats.push({ label: globalize.translate('LabelSyncPlayPlaybackDiff'), - value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit') + value: stats.PlaybackDiff + ' ' + globalize.translate('MillisecondsUnit') }); syncStats.push({ @@ -433,7 +439,7 @@ import ServerConnections from '../ServerConnections'; }); const apiClient = ServerConnections.getApiClient(playbackManager.currentItem(player).ServerId); - if (syncPlayManager.isSyncPlayEnabled() && apiClient.isMinServerVersion('10.6.0')) { + if (SyncPlay.Manager.isSyncPlayEnabled() && apiClient.isMinServerVersion('10.6.0')) { categories.push({ stats: getSyncPlayStats(), name: globalize.translate('LabelSyncPlayInfo') diff --git a/src/components/playlisteditor/playlisteditor.js b/src/components/playlisteditor/playlisteditor.js index 72bc5ec0cb8..675e8e09c99 100644 --- a/src/components/playlisteditor/playlisteditor.js +++ b/src/components/playlisteditor/playlisteditor.js @@ -3,6 +3,7 @@ import dialogHelper from '../dialogHelper/dialogHelper'; import loading from '../loading/loading'; import layoutManager from '../layoutManager'; import { playbackManager } from '../playback/playbackmanager'; +import SyncPlay from '../../components/syncPlay/core'; import * as userSettings from '../../scripts/settings/userSettings'; import { appRouter } from '../appRouter'; import globalize from '../../scripts/globalize'; @@ -48,7 +49,8 @@ import ServerConnections from '../ServerConnections'; apiClient.ajax({ type: 'POST', url: url, - dataType: 'json' + dataType: 'json', + contentType: 'application/json' }).then(result => { loading.hide(); @@ -117,7 +119,7 @@ import ServerConnections from '../ServerConnections'; apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => { let html = ''; - if (editorOptions.enableAddToPlayQueue !== false && playbackManager.isPlaying()) { + if ((editorOptions.enableAddToPlayQueue !== false && playbackManager.isPlaying()) || SyncPlay.Manager.isSyncPlayEnabled()) { html += ``; } diff --git a/src/components/pluginManager.js b/src/components/pluginManager.js index ea76d30eea8..985b76725f2 100644 --- a/src/components/pluginManager.js +++ b/src/components/pluginManager.js @@ -75,8 +75,18 @@ import { playbackManager } from './playback/playbackmanager'; if (pluginSpec in window) { console.log(`Loading plugin (via window): ${pluginSpec}`); + const pluginDefinition = await window[pluginSpec]; + if (typeof pluginDefinition !== 'function') { + throw new TypeError('Plugin definitions in window have to be an (async) function returning the plugin class'); + } + + const pluginClass = await pluginDefinition(); + if (typeof pluginClass !== 'function') { + throw new TypeError(`Plugin definition doesn't return a class for '${pluginSpec}'`); + } + // init plugin and pass basic dependencies - plugin = new window[pluginSpec]({ + plugin = new pluginClass({ events: Events, loading, appSettings, @@ -84,7 +94,8 @@ import { playbackManager } from './playback/playbackmanager'; }); } else { console.debug(`Loading plugin (via dynamic import): ${pluginSpec}`); - plugin = await import(/* webpackChunkName: "[request]" */ `../plugins/${pluginSpec}`); + const pluginResult = await import(/* webpackChunkName: "[request]" */ `../plugins/${pluginSpec}`); + plugin = new pluginResult.default; } } else if (pluginSpec.then) { console.debug('Loading plugin (via promise/async function)'); @@ -92,9 +103,7 @@ import { playbackManager } from './playback/playbackmanager'; const pluginResult = await pluginSpec; plugin = new pluginResult.default; } else { - const err = new TypeError('Plugins have to be a Promise that resolves to a plugin builder function'); - console.error(err); - throw err; + throw new TypeError('Plugins have to be a Promise that resolves to a plugin builder function'); } return this.#preparePlugin(pluginSpec, plugin); diff --git a/src/components/prompt/prompt.js b/src/components/prompt/prompt.js index c2c52bd41c1..1a919ca0258 100644 --- a/src/components/prompt/prompt.js +++ b/src/components/prompt/prompt.js @@ -9,6 +9,7 @@ import '../../elements/emby-button/emby-button'; import '../../elements/emby-button/paper-icon-button-light'; import '../../elements/emby-input/emby-input'; import '../formdialog.css'; +import template from './prompt.template.html'; /* eslint-disable indent */ export default (() => { @@ -27,7 +28,7 @@ export default (() => { txtInput.value = options.value || ''; } - function showDialog(options, template) { + function showDialog(options) { const dialogOptions = { removeOnClose: true, scrollY: false @@ -116,17 +117,13 @@ export default (() => { }; } else { return options => { - return new Promise((resolve, reject) => { - import('./prompt.template.html').then(({default: template}) => { - if (typeof options === 'string') { - options = { - title: '', - text: options - }; - } - showDialog(options, template).then(resolve, reject); - }); - }); + if (typeof options === 'string') { + options = { + title: '', + text: options + }; + } + return showDialog(options); }; } })(); diff --git a/src/components/qualityOptions.js b/src/components/qualityOptions.js index 2037cb8ccfa..bdf20e279a1 100644 --- a/src/components/qualityOptions.js +++ b/src/components/qualityOptions.js @@ -15,80 +15,53 @@ export function getVideoQualityOptions(options) { const qualityOptions = []; + const autoQualityOption = { + name: globalize.translate('Auto'), + bitrate: 0, + selected: options.isAutomaticBitrateEnabled + }; + + if (options.enableAuto) { + qualityOptions.push(autoQualityOption); + } + + // Quality options are indexed by bitrate. If you must duplicate them, make sure each of them are unique (by making the last digit a 1) if (maxAllowedWidth >= 3800) { qualityOptions.push({ name: '4K - 120 Mbps', maxHeight: 2160, bitrate: 120000000 }); - qualityOptions.push({ name: '4K - 100 Mbps', maxHeight: 2160, bitrate: 100000000 }); qualityOptions.push({ name: '4K - 80 Mbps', maxHeight: 2160, bitrate: 80000000 }); } - // Some 1080- videos are reported as 1912? if (maxAllowedWidth >= 1900) { qualityOptions.push({ name: '1080p - 60 Mbps', maxHeight: 1080, bitrate: 60000000 }); - qualityOptions.push({ name: '1080p - 50 Mbps', maxHeight: 1080, bitrate: 50000000 }); qualityOptions.push({ name: '1080p - 40 Mbps', maxHeight: 1080, bitrate: 40000000 }); - qualityOptions.push({ name: '1080p - 30 Mbps', maxHeight: 1080, bitrate: 30000000 }); - qualityOptions.push({ name: '1080p - 25 Mbps', maxHeight: 1080, bitrate: 25000000 }); qualityOptions.push({ name: '1080p - 20 Mbps', maxHeight: 1080, bitrate: 20000000 }); qualityOptions.push({ name: '1080p - 15 Mbps', maxHeight: 1080, bitrate: 15000000 }); - qualityOptions.push({ name: '1080p - 10 Mbps', maxHeight: 1080, bitrate: 10000001 }); - qualityOptions.push({ name: '1080p - 8 Mbps', maxHeight: 1080, bitrate: 8000001 }); - qualityOptions.push({ name: '1080p - 6 Mbps', maxHeight: 1080, bitrate: 6000001 }); - qualityOptions.push({ name: '1080p - 5 Mbps', maxHeight: 1080, bitrate: 5000001 }); - qualityOptions.push({ name: '1080p - 4 Mbps', maxHeight: 1080, bitrate: 4000002 }); - } else if (maxAllowedWidth >= 1260) { - qualityOptions.push({ name: '720p - 10 Mbps', maxHeight: 720, bitrate: 10000000 }); - qualityOptions.push({ name: '720p - 8 Mbps', maxHeight: 720, bitrate: 8000000 }); - qualityOptions.push({ name: '720p - 6 Mbps', maxHeight: 720, bitrate: 6000000 }); - qualityOptions.push({ name: '720p - 5 Mbps', maxHeight: 720, bitrate: 5000000 }); - } else if (maxAllowedWidth >= 620) { - qualityOptions.push({ name: '480p - 4 Mbps', maxHeight: 480, bitrate: 4000001 }); - qualityOptions.push({ name: '480p - 3 Mbps', maxHeight: 480, bitrate: 3000001 }); - qualityOptions.push({ name: '480p - 2.5 Mbps', maxHeight: 480, bitrate: 2500000 }); - qualityOptions.push({ name: '480p - 2 Mbps', maxHeight: 480, bitrate: 2000001 }); - qualityOptions.push({ name: '480p - 1.5 Mbps', maxHeight: 480, bitrate: 1500001 }); + qualityOptions.push({ name: '1080p - 10 Mbps', maxHeight: 1080, bitrate: 10000000 }); } - if (maxAllowedWidth >= 1260) { + qualityOptions.push({ name: '720p - 8 Mbps', maxHeight: 720, bitrate: 8000000 }); + qualityOptions.push({ name: '720p - 6 Mbps', maxHeight: 720, bitrate: 6000000 }); qualityOptions.push({ name: '720p - 4 Mbps', maxHeight: 720, bitrate: 4000000 }); - qualityOptions.push({ name: '720p - 3 Mbps', maxHeight: 720, bitrate: 3000000 }); - qualityOptions.push({ name: '720p - 2 Mbps', maxHeight: 720, bitrate: 2000000 }); - - // The extra 1 is because they're keyed off the bitrate value - qualityOptions.push({ name: '720p - 1.5 Mbps', maxHeight: 720, bitrate: 1500000 }); - qualityOptions.push({ name: '720p - 1 Mbps', maxHeight: 720, bitrate: 1000001 }); } - - qualityOptions.push({ name: '480p - 1 Mbps', maxHeight: 480, bitrate: 1000000 }); - qualityOptions.push({ name: '480p - 720 kbps', maxHeight: 480, bitrate: 720000 }); - qualityOptions.push({ name: '480p - 420 kbps', maxHeight: 480, bitrate: 420000 }); - qualityOptions.push({ name: '360p', maxHeight: 360, bitrate: 400000 }); - qualityOptions.push({ name: '240p', maxHeight: 240, bitrate: 320000 }); - qualityOptions.push({ name: '144p', maxHeight: 144, bitrate: 192000 }); - - const autoQualityOption = { - name: globalize.translate('Auto'), - bitrate: 0, - selected: options.isAutomaticBitrateEnabled - }; - - if (options.enableAuto) { - qualityOptions.push(autoQualityOption); + if (maxAllowedWidth >= 620) { + qualityOptions.push({ name: '480p - 3 Mbps', maxHeight: 480, bitrate: 3000000 }); + qualityOptions.push({ name: '480p - 1.5 Mbps', maxHeight: 480, bitrate: 1500000 }); + qualityOptions.push({ name: '480p - 720 kbps', maxHeight: 480, bitrate: 720000 }); } + qualityOptions.push({ name: '360p - 420 kbps', maxHeight: 360, bitrate: 420000 }); + if (maxStreamingBitrate) { - let selectedIndex = -1; + let selectedIndex = qualityOptions.length - 1; for (let i = 0, length = qualityOptions.length; i < length; i++) { const option = qualityOptions[i]; - if (selectedIndex === -1 && option.bitrate <= maxStreamingBitrate) { + if (option.bitrate > 0 && option.bitrate <= maxStreamingBitrate) { selectedIndex = i; + break; } } - if (selectedIndex === -1) { - selectedIndex = qualityOptions.length - 1; - } - const currentQualityOption = qualityOptions[selectedIndex]; if (!options.isAutomaticBitrateEnabled) { @@ -106,16 +79,6 @@ export function getAudioQualityOptions(options) { const qualityOptions = []; - qualityOptions.push({ name: '2 Mbps', bitrate: 2000000 }); - qualityOptions.push({ name: '1.5 Mbps', bitrate: 1500000 }); - qualityOptions.push({ name: '1 Mbps', bitrate: 1000000 }); - qualityOptions.push({ name: '320 kbps', bitrate: 320000 }); - qualityOptions.push({ name: '256 kbps', bitrate: 256000 }); - qualityOptions.push({ name: '192 kbps', bitrate: 192000 }); - qualityOptions.push({ name: '128 kbps', bitrate: 128000 }); - qualityOptions.push({ name: '96 kbps', bitrate: 96000 }); - qualityOptions.push({ name: '64 kbps', bitrate: 64000 }); - const autoQualityOption = { name: globalize.translate('Auto'), bitrate: 0, @@ -126,20 +89,27 @@ export function getAudioQualityOptions(options) { qualityOptions.push(autoQualityOption); } + qualityOptions.push({ name: '2 Mbps', bitrate: 2000000 }); + qualityOptions.push({ name: '1.5 Mbps', bitrate: 1500000 }); + qualityOptions.push({ name: '1 Mbps', bitrate: 1000000 }); + qualityOptions.push({ name: '320 kbps', bitrate: 320000 }); + qualityOptions.push({ name: '256 kbps', bitrate: 256000 }); + qualityOptions.push({ name: '192 kbps', bitrate: 192000 }); + qualityOptions.push({ name: '128 kbps', bitrate: 128000 }); + qualityOptions.push({ name: '96 kbps', bitrate: 96000 }); + qualityOptions.push({ name: '64 kbps', bitrate: 64000 }); + if (maxStreamingBitrate) { - let selectedIndex = -1; + let selectedIndex = qualityOptions.length - 1; for (let i = 0, length = qualityOptions.length; i < length; i++) { const option = qualityOptions[i]; - if (selectedIndex === -1 && option.bitrate <= maxStreamingBitrate) { + if (option.bitrate > 0 && option.bitrate <= maxStreamingBitrate) { selectedIndex = i; + break; } } - if (selectedIndex === -1) { - selectedIndex = qualityOptions.length - 1; - } - const currentQualityOption = qualityOptions[selectedIndex]; if (!options.isAutomaticBitrateEnabled) { diff --git a/src/components/quickConnectSettings/quickConnectSettings.js b/src/components/quickConnectSettings/quickConnectSettings.js deleted file mode 100644 index d91bc072959..00000000000 --- a/src/components/quickConnectSettings/quickConnectSettings.js +++ /dev/null @@ -1,42 +0,0 @@ -import globalize from '../../scripts/globalize'; -import toast from '../toast/toast'; -import Dashboard from '../../scripts/clientUtils'; - -export class QuickConnectSettings { - constructor() { } - - authorize(code) { - const url = ApiClient.getUrl('/QuickConnect/Authorize?Code=' + code); - ApiClient.ajax({ - type: 'POST', - url: url - }, true).then(() => { - toast(globalize.translate('QuickConnectAuthorizeSuccess')); - }).catch(() => { - toast(globalize.translate('QuickConnectAuthorizeFail')); - }); - - // prevent bubbling - return false; - } - - activate() { - const url = ApiClient.getUrl('/QuickConnect/Activate'); - return ApiClient.ajax({ - type: 'POST', - url: url - }).then(() => { - toast(globalize.translate('QuickConnectActivationSuccessful')); - return true; - }).catch((e) => { - console.error('Error activating quick connect. Error:', e); - Dashboard.alert({ - title: globalize.translate('HeaderError'), - message: globalize.translate('DefaultErrorMessage') - }); - throw e; - }); - } -} - -export default QuickConnectSettings; diff --git a/src/components/recordingcreator/recordingcreator.js b/src/components/recordingcreator/recordingcreator.js index a1545348aea..0b8e3d83073 100644 --- a/src/components/recordingcreator/recordingcreator.js +++ b/src/components/recordingcreator/recordingcreator.js @@ -18,6 +18,7 @@ import './recordingcreator.css'; import 'material-design-icons-iconfont'; import ServerConnections from '../ServerConnections'; import { playbackManager } from '../playback/playbackmanager'; +import template from './recordingcreator.template.html'; let currentDialog; let closeAction; @@ -136,64 +137,62 @@ function showEditor(itemId, serverId) { loading.show(); - import('./recordingcreator.template.html').then(({ default: template }) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } - - const dlg = dialogHelper.createDialog(dialogOptions); + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - dlg.classList.add('formDialog'); - dlg.classList.add('recordingDialog'); + const dlg = dialogHelper.createDialog(dialogOptions); - let html = ''; + dlg.classList.add('formDialog'); + dlg.classList.add('recordingDialog'); - html += globalize.translateHtml(template, 'core'); + let html = ''; - dlg.innerHTML = html; + html += globalize.translateHtml(template, 'core'); - currentDialog = dlg; + dlg.innerHTML = html; - function onRecordingChanged() { - reload(dlg, itemId, serverId, true); - } + currentDialog = dlg; - dlg.addEventListener('close', function () { - Events.off(currentRecordingFields, 'recordingchanged', onRecordingChanged); - executeCloseAction(closeAction, itemId, serverId); + function onRecordingChanged() { + reload(dlg, itemId, serverId, true); + } - if (currentRecordingFields && currentRecordingFields.hasChanged()) { - resolve(); - } else { - reject(); - } - }); + dlg.addEventListener('close', function () { + Events.off(currentRecordingFields, 'recordingchanged', onRecordingChanged); + executeCloseAction(closeAction, itemId, serverId); - if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + if (currentRecordingFields && currentRecordingFields.hasChanged()) { + resolve(); + } else { + reject(); } + }); - init(dlg); - - reload(dlg, itemId, serverId); + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + } - currentRecordingFields = new recordingFields({ - parent: dlg.querySelector('.recordingFields'), - programId: itemId, - serverId: serverId - }); + init(dlg); - Events.on(currentRecordingFields, 'recordingchanged', onRecordingChanged); + reload(dlg, itemId, serverId); - dialogHelper.open(dlg); + currentRecordingFields = new recordingFields({ + parent: dlg.querySelector('.recordingFields'), + programId: itemId, + serverId: serverId }); + + Events.on(currentRecordingFields, 'recordingchanged', onRecordingChanged); + + dialogHelper.open(dlg); }); } diff --git a/src/components/recordingcreator/recordingeditor.js b/src/components/recordingcreator/recordingeditor.js index 740321fa456..8ebb3c966e0 100644 --- a/src/components/recordingcreator/recordingeditor.js +++ b/src/components/recordingcreator/recordingeditor.js @@ -14,6 +14,7 @@ import './recordingcreator.css'; import 'material-design-icons-iconfont'; import '../../assets/css/flexstyles.scss'; import ServerConnections from '../ServerConnections'; +import template from './recordingeditor.template.html'; let currentDialog; let recordingDeleted = false; @@ -91,63 +92,61 @@ function showEditor(itemId, serverId, options) { options = options || {}; currentResolve = resolve; - import('./recordingeditor.template.html').then(({default: template}) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); - dlg.classList.add('recordingDialog'); + dlg.classList.add('formDialog'); + dlg.classList.add('recordingDialog'); - if (!layoutManager.tv) { - dlg.style['min-width'] = '20%'; - dlg.classList.add('dialog-fullscreen-lowres'); - } + if (!layoutManager.tv) { + dlg.style['min-width'] = '20%'; + dlg.classList.add('dialog-fullscreen-lowres'); + } + + let html = ''; + + html += globalize.translateHtml(template, 'core'); - let html = ''; + dlg.innerHTML = html; - html += globalize.translateHtml(template, 'core'); + if (options.enableCancel === false) { + dlg.querySelector('.formDialogFooter').classList.add('hide'); + } - dlg.innerHTML = html; + currentDialog = dlg; - if (options.enableCancel === false) { - dlg.querySelector('.formDialogFooter').classList.add('hide'); + dlg.addEventListener('closing', function () { + if (!recordingDeleted) { + dlg.querySelector('.btnSubmit').click(); } + }); - currentDialog = dlg; - - dlg.addEventListener('closing', function () { - if (!recordingDeleted) { - dlg.querySelector('.btnSubmit').click(); - } - }); - - dlg.addEventListener('close', function () { - if (recordingDeleted) { - resolve({ - updated: true, - deleted: true - }); - } - }); - - if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + dlg.addEventListener('close', function () { + if (recordingDeleted) { + resolve({ + updated: true, + deleted: true + }); } + }); - init(dlg); + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + } - reload(dlg, itemId); + init(dlg); - dialogHelper.open(dlg); - }); + reload(dlg, itemId); + + dialogHelper.open(dlg); }); } diff --git a/src/components/recordingcreator/recordingfields.js b/src/components/recordingcreator/recordingfields.js index 49843fec810..134ae6221dc 100644 --- a/src/components/recordingcreator/recordingfields.js +++ b/src/components/recordingcreator/recordingfields.js @@ -10,6 +10,7 @@ import './recordingfields.css'; import '../../assets/css/flexstyles.scss'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; +import template from './recordingfields.template.html'; /*eslint prefer-const: "error"*/ @@ -119,18 +120,16 @@ class RecordingEditor { embed() { const self = this; return new Promise(function (resolve, reject) { - import('./recordingfields.template.html').then(({default: template}) => { - const options = self.options; - const context = options.parent; - context.innerHTML = globalize.translateHtml(template, 'core'); + const options = self.options; + const context = options.parent; + context.innerHTML = globalize.translateHtml(template, 'core'); - context.querySelector('.singleRecordingButton').addEventListener('click', onRecordChange.bind(self)); - context.querySelector('.seriesRecordingButton').addEventListener('click', onRecordSeriesChange.bind(self)); - context.querySelector('.btnManageRecording').addEventListener('click', onManageRecordingClick.bind(self)); - context.querySelector('.btnManageSeriesRecording').addEventListener('click', onManageSeriesRecordingClick.bind(self)); + context.querySelector('.singleRecordingButton').addEventListener('click', onRecordChange.bind(self)); + context.querySelector('.seriesRecordingButton').addEventListener('click', onRecordSeriesChange.bind(self)); + context.querySelector('.btnManageRecording').addEventListener('click', onManageRecordingClick.bind(self)); + context.querySelector('.btnManageSeriesRecording').addEventListener('click', onManageSeriesRecordingClick.bind(self)); - fetchData(self).then(resolve); - }); + fetchData(self).then(resolve); }); } diff --git a/src/components/recordingcreator/recordinghelper.js b/src/components/recordingcreator/recordinghelper.js index 1e999ce35c5..e837fa1f275 100644 --- a/src/components/recordingcreator/recordinghelper.js +++ b/src/components/recordingcreator/recordinghelper.js @@ -180,4 +180,3 @@ export default { cancelTimerWithConfirmation: cancelTimerWithConfirmation, cancelSeriesTimerWithConfirmation: cancelSeriesTimerWithConfirmation }; - diff --git a/src/components/recordingcreator/seriesrecordingeditor.js b/src/components/recordingcreator/seriesrecordingeditor.js index c0dbd74a621..0014a9343eb 100644 --- a/src/components/recordingcreator/seriesrecordingeditor.js +++ b/src/components/recordingcreator/seriesrecordingeditor.js @@ -15,6 +15,7 @@ import './recordingcreator.css'; import 'material-design-icons-iconfont'; import '../../assets/css/flexstyles.scss'; import ServerConnections from '../ServerConnections'; +import template from './seriesrecordingeditor.template.html'; /*eslint prefer-const: "error"*/ @@ -151,38 +152,36 @@ function embed(itemId, serverId, options) { loading.show(); options = options || {}; - import('./seriesrecordingeditor.template.html').then(({ default: template }) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - const dlg = options.context; + const dlg = options.context; - dlg.classList.add('hide'); - dlg.innerHTML = globalize.translateHtml(template, 'core'); + dlg.classList.add('hide'); + dlg.innerHTML = globalize.translateHtml(template, 'core'); - dlg.querySelector('.formDialogHeader').classList.add('hide'); - dlg.querySelector('.formDialogFooter').classList.add('hide'); - dlg.querySelector('.formDialogContent').className = ''; - dlg.querySelector('.dialogContentInner').className = ''; - dlg.classList.remove('hide'); + dlg.querySelector('.formDialogHeader').classList.add('hide'); + dlg.querySelector('.formDialogFooter').classList.add('hide'); + dlg.querySelector('.formDialogContent').className = ''; + dlg.querySelector('.dialogContentInner').className = ''; + dlg.classList.remove('hide'); - dlg.removeEventListener('change', onFieldChange); - dlg.addEventListener('change', onFieldChange); + dlg.removeEventListener('change', onFieldChange); + dlg.addEventListener('change', onFieldChange); - currentDialog = dlg; + currentDialog = dlg; - init(dlg); + init(dlg); - reload(dlg, itemId); - }); + reload(dlg, itemId); } function showEditor(itemId, serverId, options) { @@ -193,66 +192,64 @@ function showEditor(itemId, serverId, options) { loading.show(); options = options || {}; - import('./seriesrecordingeditor.template.html').then(({ default: template }) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); - dlg.classList.add('recordingDialog'); + dlg.classList.add('formDialog'); + dlg.classList.add('recordingDialog'); - if (!layoutManager.tv) { - dlg.style['min-width'] = '20%'; - } + if (!layoutManager.tv) { + dlg.style['min-width'] = '20%'; + } + + let html = ''; - let html = ''; + html += globalize.translateHtml(template, 'core'); - html += globalize.translateHtml(template, 'core'); + dlg.innerHTML = html; + + if (options.enableCancel === false) { + dlg.querySelector('.formDialogFooter').classList.add('hide'); + } - dlg.innerHTML = html; + currentDialog = dlg; - if (options.enableCancel === false) { - dlg.querySelector('.formDialogFooter').classList.add('hide'); + dlg.addEventListener('closing', function () { + if (!recordingDeleted) { + this.querySelector('.btnSubmit').click(); } + }); - currentDialog = dlg; - - dlg.addEventListener('closing', function () { - if (!recordingDeleted) { - this.querySelector('.btnSubmit').click(); - } - }); - - dlg.addEventListener('close', function () { - if (recordingUpdated) { - resolve({ - updated: true, - deleted: recordingDeleted - }); - } else { - reject(); - } - }); - - if (layoutManager.tv) { - scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + dlg.addEventListener('close', function () { + if (recordingUpdated) { + resolve({ + updated: true, + deleted: recordingDeleted + }); + } else { + reject(); } + }); + + if (layoutManager.tv) { + scrollHelper.centerFocus.on(dlg.querySelector('.formDialogContent'), false); + } - init(dlg); + init(dlg); - reload(dlg, itemId); + reload(dlg, itemId); - dialogHelper.open(dlg); - }); + dialogHelper.open(dlg); }); } diff --git a/src/components/remotecontrol/remotecontrol.css b/src/components/remotecontrol/remotecontrol.css index 1c31b4382bb..9356da0d0f7 100644 --- a/src/components/remotecontrol/remotecontrol.css +++ b/src/components/remotecontrol/remotecontrol.css @@ -10,6 +10,8 @@ -webkit-box-direction: normal; -webkit-flex-direction: row; flex-direction: row; + -webkit-flex-shrink: 0; + flex-shrink: 0; } .navigationSection { @@ -51,6 +53,12 @@ display: flex; } +.infoContainer, +.sliderContainer { + -webkit-flex-shrink: 0; + flex-shrink: 0; +} + .nowPlayingInfoContainerMedia { text-align: left; margin-bottom: 1em; @@ -75,6 +83,8 @@ align-items: center; -webkit-flex-wrap: wrap; flex-wrap: wrap; + -webkit-flex-shrink: 0; + flex-shrink: 0; } .nowPlayingInfoControls, @@ -372,8 +382,7 @@ font-size: smaller; } - .paper-icon-button-light:hover { - color: #fff !important; + .paper-icon-button-light { background-color: transparent !important; } @@ -383,10 +392,6 @@ font-size: 1.7em; } - .btnPlayPause:hover { - background-color: transparent !important; - } - .nowPlayingPageImage { /* width: inherit; */ overflow-y: hidden; diff --git a/src/components/remotecontrol/remotecontrol.js b/src/components/remotecontrol/remotecontrol.js index 3ed04446518..dec95a470c4 100644 --- a/src/components/remotecontrol/remotecontrol.js +++ b/src/components/remotecontrol/remotecontrol.js @@ -147,7 +147,7 @@ function updateNowPlayingInfo(context, state, serverId) { for (const artist of item.ArtistItems) { const artistName = artist.Name; const artistId = artist.Id; - artistsSeries += `
${artistName}`; + artistsSeries += `${artistName}`; if (artist !== item.ArtistItems.slice(-1)[0]) { artistsSeries += ', '; } @@ -165,7 +165,7 @@ function updateNowPlayingInfo(context, state, serverId) { } } if (item.Album != null) { - albumName = '` + item.Album + ''; + albumName = '` + item.Album + ''; } context.querySelector('.nowPlayingAlbum').innerHTML = albumName; context.querySelector('.nowPlayingArtist').innerHTML = artistsSeries; @@ -173,12 +173,12 @@ function updateNowPlayingInfo(context, state, serverId) { } else if (item.Type == 'Episode') { if (item.SeasonName != null) { const seasonName = item.SeasonName; - context.querySelector('.nowPlayingSeason').innerHTML = '${seasonName}`; + context.querySelector('.nowPlayingSeason').innerHTML = '${seasonName}`; } if (item.SeriesName != null) { const seriesName = item.SeriesName; if (item.SeriesId != null) { - context.querySelector('.nowPlayingSerie').innerHTML = '${seriesName}`; + context.querySelector('.nowPlayingSerie').innerHTML = '${seriesName}`; } else { context.querySelector('.nowPlayingSerie').innerHTML = seriesName; } diff --git a/src/components/search/searchfields.js b/src/components/search/searchfields.js index 2bf5c03051e..67364c33b20 100644 --- a/src/components/search/searchfields.js +++ b/src/components/search/searchfields.js @@ -7,6 +7,7 @@ import '../../elements/emby-input/emby-input'; import '../../assets/css/flexstyles.scss'; import 'material-design-icons-iconfont'; import './searchfields.css'; +import template from './searchfields.template.html'; /* eslint-disable indent */ @@ -61,30 +62,28 @@ import './searchfields.css'; } function embed(elem, instance, options) { - import('./searchfields.template.html').then(({default: template}) => { - let html = globalize.translateHtml(template, 'core'); + let html = globalize.translateHtml(template, 'core'); - if (browser.tizen || browser.orsay) { - html = html.replace(' { - if (!enableScrollX()) { - template = replaceAll(template, 'data-horizontal="true"', 'data-horizontal="false"'); - template = replaceAll(template, 'itemsContainer scrollSlider', 'itemsContainer scrollSlider vertical-wrap'); - } + let workingTemplate = template; + if (!enableScrollX()) { + workingTemplate = replaceAll(workingTemplate, 'data-horizontal="true"', 'data-horizontal="false"'); + workingTemplate = replaceAll(workingTemplate, 'itemsContainer scrollSlider', 'itemsContainer scrollSlider vertical-wrap'); + } - const html = globalize.translateHtml(template, 'core'); + const html = globalize.translateHtml(workingTemplate, 'core'); - elem.innerHTML = html; + elem.innerHTML = html; - elem.classList.add('searchResults'); - instance.search(''); - }); + elem.classList.add('searchResults'); + instance.search(''); } class SearchResults { diff --git a/src/components/shortcuts.js b/src/components/shortcuts.js index fe7bfefc90a..b3d82c4988a 100644 --- a/src/components/shortcuts.js +++ b/src/components/shortcuts.js @@ -145,7 +145,10 @@ import toast from './toast/toast'; SeriesId: card.getAttribute('data-seriesid'), ServerId: card.getAttribute('data-serverid'), MediaType: card.getAttribute('data-mediatype'), + Path: card.getAttribute('data-path'), IsFolder: card.getAttribute('data-isfolder') === 'true', + StartDate: card.getAttribute('data-startdate'), + EndDate: card.getAttribute('data-enddate'), UserData: { PlaybackPositionTicks: parseInt(card.getAttribute('data-positionticks') || '0') } @@ -204,11 +207,15 @@ import toast from './toast/toast'; } else if (action === 'play' || action === 'resume') { const startPositionTicks = parseInt(card.getAttribute('data-positionticks') || '0'); - playbackManager.play({ - ids: [playableItemId], - startPositionTicks: startPositionTicks, - serverId: serverId - }); + if (playbackManager.canPlay(item)) { + playbackManager.play({ + ids: [playableItemId], + startPositionTicks: startPositionTicks, + serverId: serverId + }); + } else { + console.warn('Unable to play item', item); + } } else if (action === 'queue') { if (playbackManager.isPlaying()) { playbackManager.queue({ diff --git a/src/components/sortmenu/sortmenu.js b/src/components/sortmenu/sortmenu.js index 0ec21d3b774..c8eaf80b7f1 100644 --- a/src/components/sortmenu/sortmenu.js +++ b/src/components/sortmenu/sortmenu.js @@ -8,6 +8,7 @@ import 'material-design-icons-iconfont'; import '../formdialog.css'; import '../../elements/emby-button/emby-button'; import '../../assets/css/flexstyles.scss'; +import template from './sortmenu.template.html'; function onSubmit(e) { e.preventDefault(); @@ -44,64 +45,62 @@ function saveValues(context, settingsKey) { class SortMenu { show(options) { return new Promise(function (resolve, reject) { - import('./sortmenu.template.html').then(({default: template}) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); + dlg.classList.add('formDialog'); - let html = ''; + let html = ''; - html += '
'; - html += ''; - html += '

${Sort}

'; + html += '
'; + html += ''; + html += '

${Sort}

'; - html += '
'; + html += '
'; - html += template; + html += template; - dlg.innerHTML = globalize.translateHtml(html, 'core'); + dlg.innerHTML = globalize.translateHtml(html, 'core'); - fillSortBy(dlg, options.sortOptions); - initEditor(dlg, options.settings); + fillSortBy(dlg, options.sortOptions); + initEditor(dlg, options.settings); - dlg.querySelector('.btnCancel').addEventListener('click', function () { - dialogHelper.close(dlg); - }); + dlg.querySelector('.btnCancel').addEventListener('click', function () { + dialogHelper.close(dlg); + }); - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, true); - } + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, true); + } - let submitted; + let submitted; - dlg.querySelector('form').addEventListener('change', function () { - submitted = true; - }, true); + dlg.querySelector('form').addEventListener('change', function () { + submitted = true; + }, true); - dialogHelper.open(dlg).then(function () { - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, false); - } + dialogHelper.open(dlg).then(function () { + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, false); + } - if (submitted) { - saveValues(dlg, options.settingsKey); - resolve(); - return; - } + if (submitted) { + saveValues(dlg, options.settingsKey); + resolve(); + return; + } - reject(); - }); + reject(); }); }); } diff --git a/src/components/subtitleeditor/subtitleeditor.js b/src/components/subtitleeditor/subtitleeditor.js index 980a5ef7357..5e026538e35 100644 --- a/src/components/subtitleeditor/subtitleeditor.js +++ b/src/components/subtitleeditor/subtitleeditor.js @@ -17,6 +17,7 @@ import '../../assets/css/flexstyles.scss'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; import confirm from '../confirm/confirm'; +import template from './subtitleeditor.template.html'; let currentItem; let hasChanges; @@ -374,7 +375,7 @@ function onOpenUploadMenu(e) { }); } -function showEditorInternal(itemId, serverId, template) { +function showEditorInternal(itemId, serverId) { hasChanges = false; const apiClient = ServerConnections.getApiClient(serverId); @@ -453,11 +454,7 @@ function showEditorInternal(itemId, serverId, template) { function showEditor(itemId, serverId) { loading.show(); - return new Promise(function (resolve, reject) { - import('./subtitleeditor.template.html').then(({default: template}) => { - showEditorInternal(itemId, serverId, template).then(resolve, reject); - }); - }); + return showEditorInternal(itemId, serverId); } export default { diff --git a/src/components/subtitlesettings/subtitlesettings.js b/src/components/subtitlesettings/subtitlesettings.js index f49734b1434..d348ae63598 100644 --- a/src/components/subtitlesettings/subtitlesettings.js +++ b/src/components/subtitlesettings/subtitlesettings.js @@ -17,6 +17,7 @@ import '../../assets/css/flexstyles.scss'; import './subtitlesettings.css'; import ServerConnections from '../ServerConnections'; import toast from '../toast/toast'; +import template from './subtitlesettings.template.html'; /** * Subtitle settings. @@ -158,63 +159,61 @@ function hideSubtitlePreview(persistent) { } function embed(options, self) { - import('./subtitlesettings.template.html').then(({default: template}) => { - options.element.classList.add('subtitlesettings'); - options.element.innerHTML = globalize.translateHtml(template, 'core'); + options.element.classList.add('subtitlesettings'); + options.element.innerHTML = globalize.translateHtml(template, 'core'); - options.element.querySelector('form').addEventListener('submit', self.onSubmit.bind(self)); + options.element.querySelector('form').addEventListener('submit', self.onSubmit.bind(self)); - options.element.querySelector('#selectSubtitlePlaybackMode').addEventListener('change', onSubtitleModeChange); - options.element.querySelector('#selectTextSize').addEventListener('change', onAppearanceFieldChange); - options.element.querySelector('#selectDropShadow').addEventListener('change', onAppearanceFieldChange); - options.element.querySelector('#selectFont').addEventListener('change', onAppearanceFieldChange); - options.element.querySelector('#inputTextColor').addEventListener('change', onAppearanceFieldChange); - options.element.querySelector('#inputTextBackground').addEventListener('change', onAppearanceFieldChange); + options.element.querySelector('#selectSubtitlePlaybackMode').addEventListener('change', onSubtitleModeChange); + options.element.querySelector('#selectTextSize').addEventListener('change', onAppearanceFieldChange); + options.element.querySelector('#selectDropShadow').addEventListener('change', onAppearanceFieldChange); + options.element.querySelector('#selectFont').addEventListener('change', onAppearanceFieldChange); + options.element.querySelector('#inputTextColor').addEventListener('change', onAppearanceFieldChange); + options.element.querySelector('#inputTextBackground').addEventListener('change', onAppearanceFieldChange); - if (options.enableSaveButton) { - options.element.querySelector('.btnSave').classList.remove('hide'); - } - - if (appHost.supports('subtitleappearancesettings')) { - options.element.querySelector('.subtitleAppearanceSection').classList.remove('hide'); + if (options.enableSaveButton) { + options.element.querySelector('.btnSave').classList.remove('hide'); + } - self._fullPreview = options.element.querySelector('.subtitleappearance-fullpreview'); - self._refFullPreview = 0; + if (appHost.supports('subtitleappearancesettings')) { + options.element.querySelector('.subtitleAppearanceSection').classList.remove('hide'); - const sliderVerticalPosition = options.element.querySelector('#sliderVerticalPosition'); - sliderVerticalPosition.addEventListener('input', onAppearanceFieldChange); - sliderVerticalPosition.addEventListener('input', () => showSubtitlePreview.call(self)); + self._fullPreview = options.element.querySelector('.subtitleappearance-fullpreview'); + self._refFullPreview = 0; - const eventPrefix = window.PointerEvent ? 'pointer' : 'mouse'; - sliderVerticalPosition.addEventListener(`${eventPrefix}enter`, () => showSubtitlePreview.call(self, true)); - sliderVerticalPosition.addEventListener(`${eventPrefix}leave`, () => hideSubtitlePreview.call(self, true)); + const sliderVerticalPosition = options.element.querySelector('#sliderVerticalPosition'); + sliderVerticalPosition.addEventListener('input', onAppearanceFieldChange); + sliderVerticalPosition.addEventListener('input', () => showSubtitlePreview.call(self)); - if (layoutManager.tv) { - sliderVerticalPosition.addEventListener('focus', () => showSubtitlePreview.call(self, true)); - sliderVerticalPosition.addEventListener('blur', () => hideSubtitlePreview.call(self, true)); + const eventPrefix = window.PointerEvent ? 'pointer' : 'mouse'; + sliderVerticalPosition.addEventListener(`${eventPrefix}enter`, () => showSubtitlePreview.call(self, true)); + sliderVerticalPosition.addEventListener(`${eventPrefix}leave`, () => hideSubtitlePreview.call(self, true)); - // Give CustomElements time to attach - setTimeout(() => { - sliderVerticalPosition.classList.add('focusable'); - sliderVerticalPosition.enableKeyboardDragging(); - }, 0); - } + if (layoutManager.tv) { + sliderVerticalPosition.addEventListener('focus', () => showSubtitlePreview.call(self, true)); + sliderVerticalPosition.addEventListener('blur', () => hideSubtitlePreview.call(self, true)); - options.element.querySelector('.chkPreview').addEventListener('change', (e) => { - if (e.target.checked) { - showSubtitlePreview.call(self, true); - } else { - hideSubtitlePreview.call(self, true); - } - }); + // Give CustomElements time to attach + setTimeout(() => { + sliderVerticalPosition.classList.add('focusable'); + sliderVerticalPosition.enableKeyboardDragging(); + }, 0); } - self.loadData(); + options.element.querySelector('.chkPreview').addEventListener('change', (e) => { + if (e.target.checked) { + showSubtitlePreview.call(self, true); + } else { + hideSubtitlePreview.call(self, true); + } + }); + } + + self.loadData(); - if (options.autoFocus) { - focusManager.autoFocus(options.element); - } - }); + if (options.autoFocus) { + focusManager.autoFocus(options.element); + } } export class SubtitleSettings { diff --git a/src/components/subtitlesync/subtitlesync.js b/src/components/subtitlesync/subtitlesync.js index d3477932c38..f04ce58e7ad 100644 --- a/src/components/subtitlesync/subtitlesync.js +++ b/src/components/subtitlesync/subtitlesync.js @@ -45,11 +45,11 @@ function init(instance) { let inputOffset = /[-+]?\d+\.?\d*/g.exec(this.textContent); if (inputOffset) { inputOffset = inputOffset[0]; + inputOffset = parseFloat(inputOffset); + inputOffset = Math.min(30, Math.max(-30, inputOffset)); // replace current text by considered offset this.textContent = inputOffset + 's'; - - inputOffset = parseFloat(inputOffset); // set new offset playbackManager.setSubtitleOffset(inputOffset, player); // synchronize with slider value @@ -121,7 +121,7 @@ function getPercentageFromOffset(value) { // convert fraction to percent percentValue *= 50; percentValue += 50; - return Math.min(100, Math.max(0, percentValue.toFixed())); + return Math.min(100, Math.max(0, percentValue.toFixed(1))); } class SubtitleSync { diff --git a/src/components/subtitlesync/subtitlesync.template.html b/src/components/subtitlesync/subtitlesync.template.html index fe202ebf606..8de75416351 100644 --- a/src/components/subtitlesync/subtitlesync.template.html +++ b/src/components/subtitlesync/subtitlesync.template.html @@ -3,7 +3,7 @@
0s
- +
diff --git a/src/components/syncPlay/core/Controller.js b/src/components/syncPlay/core/Controller.js new file mode 100644 index 00000000000..695ccde9f10 --- /dev/null +++ b/src/components/syncPlay/core/Controller.js @@ -0,0 +1,221 @@ +/** + * Module that exposes SyncPlay calls to external modules. + * @module components/syncPlay/core/Controller + */ + +import * as Helper from './Helper'; + +/** + * Class that exposes SyncPlay calls to external modules. + */ +class Controller { + constructor() { + this.manager = null; + } + + /** + * Initializes the controller. + * @param {Manager} syncPlayManager The SyncPlay manager. + */ + init(syncPlayManager) { + this.manager = syncPlayManager; + } + + /** + * Toggles playback status in SyncPlay group. + */ + playPause() { + if (this.manager.isPlaying()) { + this.pause(); + } else { + this.unpause(); + } + } + + /** + * Unpauses playback in SyncPlay group. + */ + unpause() { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayUnpause(); + } + + /** + * Pauses playback in SyncPlay group. + */ + pause() { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayPause(); + + // Pause locally as well, to give the user some little control. + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localPause(); + } + + /** + * Seeks playback to specified position in SyncPlay group. + * @param {number} positionTicks The position. + */ + seek(positionTicks) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySeek({ + PositionTicks: positionTicks + }); + } + + /** + * Starts playback in SyncPlay group. + * @param {Object} options The play data. + */ + play(options) { + const apiClient = this.manager.getApiClient(); + const sendPlayRequest = (items) => { + const queue = items.map(item => item.Id); + apiClient.requestSyncPlaySetNewQueue({ + PlayingQueue: queue, + PlayingItemPosition: options.startIndex ? options.startIndex : 0, + StartPositionTicks: options.startPositionTicks ? options.startPositionTicks : 0 + }); + }; + + if (options.items) { + Helper.translateItemsForPlayback(apiClient, options.items, options).then(sendPlayRequest); + } else { + Helper.getItemsForPlayback(apiClient, { + Ids: options.ids.join(',') + }).then(function (result) { + Helper.translateItemsForPlayback(apiClient, result.Items, options).then(sendPlayRequest); + }); + } + } + + /** + * Sets current playing item in SyncPlay group. + * @param {string} playlistItemId The item playlist identifier. + */ + setCurrentPlaylistItem(playlistItemId) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySetPlaylistItem({ + PlaylistItemId: playlistItemId + }); + } + + /** + * Removes items from SyncPlay group playlist. + * @param {Array} playlistItemIds The items to remove. + */ + removeFromPlaylist(playlistItemIds) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayRemoveFromPlaylist({ + PlaylistItemIds: playlistItemIds + }); + } + + /** + * Moves an item in the SyncPlay group playlist. + * @param {string} playlistItemId The item playlist identifier. + * @param {number} newIndex The new position. + */ + movePlaylistItem(playlistItemId, newIndex) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayMovePlaylistItem({ + PlaylistItemId: playlistItemId, + NewIndex: newIndex + }); + } + + /** + * Adds items to the SyncPlay group playlist. + * @param {Object} options The items to add. + * @param {string} mode The queue mode, optional. + */ + queue(options, mode = 'Queue') { + const apiClient = this.manager.getApiClient(); + if (options.items) { + Helper.translateItemsForPlayback(apiClient, options.items, options).then((items) => { + const itemIds = items.map(item => item.Id); + apiClient.requestSyncPlayQueue({ + ItemIds: itemIds, + Mode: mode + }); + }); + } else { + Helper.getItemsForPlayback(apiClient, { + Ids: options.ids.join(',') + }).then(function (result) { + Helper.translateItemsForPlayback(apiClient, result.Items, options).then((items) => { + const itemIds = items.map(item => item.Id); + apiClient.requestSyncPlayQueue({ + ItemIds: itemIds, + Mode: mode + }); + }); + }); + } + } + + /** + * Adds items to the SyncPlay group playlist after the playing item. + * @param {Object} options The items to add. + */ + queueNext(options) { + this.queue(options, 'QueueNext'); + } + + /** + * Plays next item from playlist in SyncPlay group. + */ + nextItem() { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayNextItem({ + PlaylistItemId: this.manager.getQueueCore().getCurrentPlaylistItemId() + }); + } + + /** + * Plays previous item from playlist in SyncPlay group. + */ + previousItem() { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayPreviousItem({ + PlaylistItemId: this.manager.getQueueCore().getCurrentPlaylistItemId() + }); + } + + /** + * Sets the repeat mode in SyncPlay group. + * @param {string} mode The repeat mode. + */ + setRepeatMode(mode) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySetRepeatMode({ + Mode: mode + }); + } + + /** + * Sets the shuffle mode in SyncPlay group. + * @param {string} mode The shuffle mode. + */ + setShuffleMode(mode) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySetShuffleMode({ + Mode: mode + }); + } + + /** + * Toggles the shuffle mode in SyncPlay group. + */ + toggleShuffleMode() { + let mode = this.manager.getQueueCore().getShuffleMode(); + mode = mode === 'Sorted' ? 'Shuffle' : 'Sorted'; + + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySetShuffleMode({ + Mode: mode + }); + } +} + +export default Controller; diff --git a/src/components/syncPlay/core/Helper.js b/src/components/syncPlay/core/Helper.js new file mode 100644 index 00000000000..53b47c47ddc --- /dev/null +++ b/src/components/syncPlay/core/Helper.js @@ -0,0 +1,238 @@ +/** + * Module that offers some utility functions. + * @module components/syncPlay/core/Helper + */ + +import { Events } from 'jellyfin-apiclient'; + +/** + * Constants + */ +export const WaitForEventDefaultTimeout = 30000; // milliseconds +export const WaitForPlayerEventTimeout = 500; // milliseconds +export const TicksPerMillisecond = 10000.0; + +/** + * Waits for an event to be triggered on an object. An optional timeout can specified after which the promise is rejected. + * @param {Object} emitter Object on which to listen for events. + * @param {string} eventType Event name to listen for. + * @param {number} timeout Time before rejecting promise if event does not trigger, in milliseconds. + * @param {Array} rejectEventTypes Event names to listen for and abort the waiting. + * @returns {Promise} A promise that resolves when the event is triggered. + */ +export function waitForEventOnce(emitter, eventType, timeout, rejectEventTypes) { + return new Promise((resolve, reject) => { + let rejectTimeout; + if (timeout) { + rejectTimeout = setTimeout(() => { + reject('Timed out.'); + }, timeout); + } + + const clearAll = () => { + Events.off(emitter, eventType, callback); + + if (rejectTimeout) { + clearTimeout(rejectTimeout); + } + + if (Array.isArray(rejectEventTypes)) { + rejectEventTypes.forEach(eventName => { + Events.off(emitter, eventName, rejectCallback); + }); + } + }; + + const callback = () => { + clearAll(); + resolve(arguments); + }; + + const rejectCallback = (event) => { + clearAll(); + reject(event.type); + }; + + Events.on(emitter, eventType, callback); + + if (Array.isArray(rejectEventTypes)) { + rejectEventTypes.forEach(eventName => { + Events.on(emitter, eventName, rejectCallback); + }); + } + }); +} + +/** + * Converts a given string to a Guid string. + * @param {string} input The input string. + * @returns {string} The Guid string. + */ +export function stringToGuid(input) { + return input.replace(/([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/, '$1-$2-$3-$4-$5'); +} + +/** + * Triggers a show-message event. + * @param {Object} syncPlayManager The SyncPlay manager. + * @param {string} message The message name. + * @param {Array} args Extra data needed for the message, optional. + */ +export function showMessage(syncPlayManager, message, args = []) { + Events.trigger(syncPlayManager, 'show-message', [{ + message: message, + args: args + }]); +} + +export function getItemsForPlayback(apiClient, query) { + if (query.Ids && query.Ids.split(',').length === 1) { + const itemId = query.Ids.split(','); + + return apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) { + return { + Items: [item], + TotalRecordCount: 1 + }; + }); + } else { + query.Limit = query.Limit || 300; + query.Fields = 'Chapters'; + query.ExcludeLocationTypes = 'Virtual'; + query.EnableTotalRecordCount = false; + query.CollapseBoxSetItems = false; + + return apiClient.getItems(apiClient.getCurrentUserId(), query); + } +} + +function mergePlaybackQueries(obj1, obj2) { + const query = Object.assign(obj1, obj2); + + const filters = query.Filters ? query.Filters.split(',') : []; + if (filters.indexOf('IsNotFolder') === -1) { + filters.push('IsNotFolder'); + } + query.Filters = filters.join(','); + return query; +} + +export function translateItemsForPlayback(apiClient, items, options) { + if (items.length > 1 && options && options.ids) { + // Use the original request id array for sorting the result in the proper order. + items.sort(function (a, b) { + return options.ids.indexOf(a.Id) - options.ids.indexOf(b.Id); + }); + } + + const firstItem = items[0]; + let promise; + + const queryOptions = options.queryOptions || {}; + + if (firstItem.Type === 'Program') { + promise = getItemsForPlayback(apiClient, { + Ids: firstItem.ChannelId + }); + } else if (firstItem.Type === 'Playlist') { + promise = getItemsForPlayback(apiClient, { + ParentId: firstItem.Id, + SortBy: options.shuffle ? 'Random' : null + }); + } else if (firstItem.Type === 'MusicArtist') { + promise = getItemsForPlayback(apiClient, { + ArtistIds: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: options.shuffle ? 'Random' : 'SortName', + MediaTypes: 'Audio' + }); + } else if (firstItem.MediaType === 'Photo') { + promise = getItemsForPlayback(apiClient, { + ParentId: firstItem.ParentId, + Filters: 'IsNotFolder', + // Setting this to true may cause some incorrect sorting. + Recursive: false, + SortBy: options.shuffle ? 'Random' : 'SortName', + MediaTypes: 'Photo,Video' + }).then(function (result) { + let index = result.Items.map(function (i) { + return i.Id; + }).indexOf(firstItem.Id); + + if (index === -1) { + index = 0; + } + + options.startIndex = index; + + return Promise.resolve(result); + }); + } else if (firstItem.Type === 'PhotoAlbum') { + promise = getItemsForPlayback(apiClient, { + ParentId: firstItem.Id, + Filters: 'IsNotFolder', + // Setting this to true may cause some incorrect sorting. + Recursive: false, + SortBy: options.shuffle ? 'Random' : 'SortName', + MediaTypes: 'Photo,Video', + Limit: 1000 + }); + } else if (firstItem.Type === 'MusicGenre') { + promise = getItemsForPlayback(apiClient, { + GenreIds: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: options.shuffle ? 'Random' : 'SortName', + MediaTypes: 'Audio' + }); + } else if (firstItem.IsFolder) { + promise = getItemsForPlayback(apiClient, mergePlaybackQueries({ + ParentId: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + // These are pre-sorted. + SortBy: options.shuffle ? 'Random' : (['BoxSet'].indexOf(firstItem.Type) === -1 ? 'SortName' : null), + MediaTypes: 'Audio,Video' + }, queryOptions)); + } else if (firstItem.Type === 'Episode' && items.length === 1) { + promise = new Promise(function (resolve, reject) { + apiClient.getCurrentUser().then(function (user) { + if (!user.Configuration.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) { + resolve(null); + return; + } + + apiClient.getEpisodes(firstItem.SeriesId, { + IsVirtualUnaired: false, + IsMissing: false, + UserId: apiClient.getCurrentUserId(), + Fields: 'Chapters' + }).then(function (episodesResult) { + let foundItem = false; + episodesResult.Items = episodesResult.Items.filter(function (e) { + if (foundItem) { + return true; + } + if (e.Id === firstItem.Id) { + foundItem = true; + return true; + } + + return false; + }); + episodesResult.TotalRecordCount = episodesResult.Items.length; + resolve(episodesResult); + }, reject); + }); + }); + } + + if (promise) { + return promise.then(function (result) { + return result ? result.Items : items; + }); + } else { + return Promise.resolve(items); + } +} diff --git a/src/components/syncPlay/core/Manager.js b/src/components/syncPlay/core/Manager.js new file mode 100644 index 00000000000..18f4844baed --- /dev/null +++ b/src/components/syncPlay/core/Manager.js @@ -0,0 +1,481 @@ +/** + * Module that manages the SyncPlay feature. + * @module components/syncPlay/core/Manager + */ + +import { Events } from 'jellyfin-apiclient'; +import * as Helper from './Helper'; +import TimeSyncCore from './timeSync/TimeSyncCore'; +import PlaybackCore from './PlaybackCore'; +import QueueCore from './QueueCore'; +import Controller from './Controller'; + +/** + * Class that manages the SyncPlay feature. + */ +class Manager { + /** + * Creates an instance of SyncPlay Manager. + * @param {PlayerFactory} playerFactory The PlayerFactory instance. + */ + constructor(playerFactory) { + this.playerFactory = playerFactory; + this.apiClient = null; + + this.timeSyncCore = new TimeSyncCore(); + this.playbackCore = new PlaybackCore(); + this.queueCore = new QueueCore(); + this.controller = new Controller(); + + this.syncMethod = 'None'; // Used for stats. + + this.groupInfo = null; + this.syncPlayEnabledAt = null; // Server time of when SyncPlay has been enabled. + this.syncPlayReady = false; // SyncPlay is ready after first ping to server. + this.queuedCommand = null; // Queued playback command, applied when SyncPlay is ready. + this.followingGroupPlayback = true; // Follow or ignore group playback. + this.lastPlaybackCommand = null; // Last received playback command from server, tracks state of group. + + this.currentPlayer = null; + this.playerWrapper = null; + } + + /** + * Initialise SyncPlay. + * @param {Object} apiClient The ApiClient. + */ + init(apiClient) { + if (!apiClient) { + throw new Error('ApiClient is null!'); + } + + // Set ApiClient. + this.apiClient = apiClient; + + // Get default player wrapper. + this.playerWrapper = this.playerFactory.getDefaultWrapper(this); + + // Initialize components. + this.timeSyncCore.init(this); + this.playbackCore.init(this); + this.queueCore.init(this); + this.controller.init(this); + + Events.on(this.timeSyncCore, 'time-sync-server-update', (event, timeOffset, ping) => { + // Report ping back to server. + if (this.syncEnabled) { + this.getApiClient().sendSyncPlayPing({ + Ping: ping + }); + } + }); + } + + /** + * Gets the time sync core. + * @returns {TimeSyncCore} The time sync core. + */ + getTimeSyncCore() { + return this.timeSyncCore; + } + + /** + * Gets the playback core. + * @returns {PlaybackCore} The playback core. + */ + getPlaybackCore() { + return this.playbackCore; + } + + /** + * Gets the queue core. + * @returns {QueueCore} The queue core. + */ + getQueueCore() { + return this.queueCore; + } + + /** + * Gets the controller used to manage SyncPlay playback. + * @returns {Controller} The controller. + */ + getController() { + return this.controller; + } + + /** + * Gets the player wrapper used to control local playback. + * @returns {SyncPlayGenericPlayer} The player wrapper. + */ + getPlayerWrapper() { + return this.playerWrapper; + } + + /** + * Gets the ApiClient used to communicate with the server. + * @returns {Object} The ApiClient. + */ + getApiClient() { + return this.apiClient; + } + + /** + * Gets the last playback command, if any. + * @returns {Object} The playback command. + */ + getLastPlaybackCommand() { + return this.lastPlaybackCommand; + } + + /** + * Called when the player changes. + */ + onPlayerChange(newPlayer, newTarget, oldPlayer) { + this.bindToPlayer(newPlayer); + } + + /** + * Binds to the player's events. + * @param {Object} player The player. + */ + bindToPlayer(player) { + this.releaseCurrentPlayer(); + + if (!player) { + return; + } + + this.playerWrapper.unbindFromPlayer(); + + this.currentPlayer = player; + this.playerWrapper = this.playerFactory.getWrapper(player, this); + + if (this.isSyncPlayEnabled()) { + this.playerWrapper.bindToPlayer(); + } + + Events.trigger(this, 'playerchange', [this.currentPlayer]); + } + + /** + * Removes the bindings from the current player's events. + */ + releaseCurrentPlayer() { + this.currentPlayer = null; + this.playerWrapper.unbindFromPlayer(); + + this.playerWrapper = this.playerFactory.getDefaultWrapper(this); + if (this.isSyncPlayEnabled()) { + this.playerWrapper.bindToPlayer(); + } + + Events.trigger(this, 'playerchange', [this.currentPlayer]); + } + + /** + * Handles a group update from the server. + * @param {Object} cmd The group update. + * @param {Object} apiClient The ApiClient. + */ + processGroupUpdate(cmd, apiClient) { + switch (cmd.Type) { + case 'PlayQueue': + this.queueCore.updatePlayQueue(apiClient, cmd.Data); + break; + case 'UserJoined': + Helper.showMessage(this, 'MessageSyncPlayUserJoined', [cmd.Data]); + break; + case 'UserLeft': + Helper.showMessage(this, 'MessageSyncPlayUserLeft', [cmd.Data]); + break; + case 'GroupJoined': + cmd.Data.LastUpdatedAt = new Date(cmd.Data.LastUpdatedAt); + this.enableSyncPlay(apiClient, cmd.Data, true); + break; + case 'SyncPlayIsDisabled': + Helper.showMessage(this, 'MessageSyncPlayIsDisabled'); + break; + case 'NotInGroup': + case 'GroupLeft': + this.disableSyncPlay(true); + break; + case 'GroupUpdate': + cmd.Data.LastUpdatedAt = new Date(cmd.Data.LastUpdatedAt); + this.groupInfo = cmd.Data; + break; + case 'StateUpdate': + Events.trigger(this, 'group-state-update', [cmd.Data.State, cmd.Data.Reason]); + console.debug(`SyncPlay processGroupUpdate: state changed to ${cmd.Data.State} because ${cmd.Data.Reason}.`); + break; + case 'GroupDoesNotExist': + Helper.showMessage(this, 'MessageSyncPlayGroupDoesNotExist'); + break; + case 'CreateGroupDenied': + Helper.showMessage(this, 'MessageSyncPlayCreateGroupDenied'); + break; + case 'JoinGroupDenied': + Helper.showMessage(this, 'MessageSyncPlayJoinGroupDenied'); + break; + case 'LibraryAccessDenied': + Helper.showMessage(this, 'MessageSyncPlayLibraryAccessDenied'); + break; + default: + console.error(`SyncPlay processGroupUpdate: command ${cmd.Type} not recognised.`); + break; + } + } + + /** + * Handles a playback command from the server. + * @param {Object} cmd The playback command. + * @param {Object} apiClient The ApiClient. + */ + processCommand(cmd, apiClient) { + if (cmd === null) return; + + if (typeof cmd.When === 'string') { + cmd.When = new Date(cmd.When); + cmd.EmittedAt = new Date(cmd.EmittedAt); + cmd.PositionTicks = cmd.PositionTicks ? parseInt(cmd.PositionTicks) : null; + } + + if (!this.isSyncPlayEnabled()) { + console.debug('SyncPlay processCommand: SyncPlay not enabled, ignoring command.', cmd); + return; + } + + if (cmd.EmittedAt.getTime() < this.syncPlayEnabledAt.getTime()) { + console.debug('SyncPlay processCommand: ignoring old command.', cmd); + return; + } + + if (!this.syncPlayReady) { + console.debug('SyncPlay processCommand: SyncPlay not ready, queued command.', cmd); + this.queuedCommand = cmd; + return; + } + + this.lastPlaybackCommand = cmd; + + if (!this.isPlaybackActive()) { + console.debug('SyncPlay processCommand: no active player!'); + return; + } + + // Make sure command matches playing item in playlist. + const playlistItemId = this.queueCore.getCurrentPlaylistItemId(); + if (cmd.PlaylistItemId !== playlistItemId && cmd.Command !== 'Stop') { + console.error('SyncPlay processCommand: playlist item does not match!', cmd); + return; + } + + console.log(`SyncPlay will ${cmd.Command} at ${cmd.When} (in ${cmd.When.getTime() - Date.now()} ms)${cmd.PositionTicks ? '' : ' from ' + cmd.PositionTicks}.`); + + this.playbackCore.applyCommand(cmd); + } + + /** + * Handles a group state change. + * @param {Object} update The group state update. + * @param {Object} apiClient The ApiClient. + */ + processStateChange(update, apiClient) { + if (update === null || update.State === null || update.Reason === null) return; + + if (!this.isSyncPlayEnabled()) { + console.debug('SyncPlay processStateChange: SyncPlay not enabled, ignoring group state update.', update); + return; + } + + Events.trigger(this, 'group-state-change', [update.State, update.Reason]); + } + + /** + * Notifies server that this client is following group's playback. + * @param {Object} apiClient The ApiClient. + * @returns {Promise} A Promise fulfilled upon request completion. + */ + followGroupPlayback(apiClient) { + this.followingGroupPlayback = true; + + return apiClient.requestSyncPlaySetIgnoreWait({ + IgnoreWait: false + }); + } + + /** + * Starts this client's playback and loads the group's play queue. + * @param {Object} apiClient The ApiClient. + */ + resumeGroupPlayback(apiClient) { + this.followGroupPlayback(apiClient).then(() => { + this.queueCore.startPlayback(apiClient); + }); + } + + /** + * Stops this client's playback and notifies server to be ignored in group wait. + * @param {Object} apiClient The ApiClient. + */ + haltGroupPlayback(apiClient) { + this.followingGroupPlayback = false; + + apiClient.requestSyncPlaySetIgnoreWait({ + IgnoreWait: true + }); + this.playbackCore.localStop(); + } + + /** + * Whether this client is following group playback. + * @returns {boolean} _true_ if client should play group's content, _false_ otherwise. + */ + isFollowingGroupPlayback() { + return this.followingGroupPlayback; + } + + /** + * Enables SyncPlay. + * @param {Object} apiClient The ApiClient. + * @param {Object} groupInfo The joined group's info. + * @param {boolean} showMessage Display message. + */ + enableSyncPlay(apiClient, groupInfo, showMessage = false) { + if (this.isSyncPlayEnabled()) { + if (groupInfo.GroupId === this.groupInfo.GroupId) { + console.debug(`SyncPlay enableSyncPlay: group ${this.groupInfo.GroupId} already joined.`); + return; + } else { + console.warn(`SyncPlay enableSyncPlay: switching from group ${this.groupInfo.GroupId} to group ${groupInfo.GroupId}.`); + this.disableSyncPlay(false); + } + + showMessage = false; + } + + this.groupInfo = groupInfo; + + this.syncPlayEnabledAt = groupInfo.LastUpdatedAt; + this.playerWrapper.bindToPlayer(); + + Events.trigger(this, 'enabled', [true]); + + // Wait for time sync to be ready. + Helper.waitForEventOnce(this.timeSyncCore, 'time-sync-server-update').then(() => { + this.syncPlayReady = true; + this.processCommand(this.queuedCommand, apiClient); + this.queuedCommand = null; + }); + + this.syncPlayReady = false; + this.followingGroupPlayback = true; + + this.timeSyncCore.forceUpdate(); + + if (showMessage) { + Helper.showMessage(this, 'MessageSyncPlayEnabled'); + } + } + + /** + * Disables SyncPlay. + * @param {boolean} showMessage Display message. + */ + disableSyncPlay(showMessage = false) { + this.syncPlayEnabledAt = null; + this.syncPlayReady = false; + this.followingGroupPlayback = true; + this.lastPlaybackCommand = null; + this.queuedCommand = null; + this.playbackCore.syncEnabled = false; + Events.trigger(this, 'enabled', [false]); + this.playerWrapper.unbindFromPlayer(); + + if (showMessage) { + Helper.showMessage(this, 'MessageSyncPlayDisabled'); + } + } + + /** + * Gets SyncPlay status. + * @returns {boolean} _true_ if user joined a group, _false_ otherwise. + */ + isSyncPlayEnabled() { + return this.syncPlayEnabledAt !== null; + } + + /** + * Gets the group information. + * @returns {Object} The group information, null if SyncPlay is disabled. + */ + getGroupInfo() { + return this.groupInfo; + } + + /** + * Gets SyncPlay stats. + * @returns {Object} The SyncPlay stats. + */ + getStats() { + return { + TimeSyncDevice: this.timeSyncCore.getActiveDeviceName(), + TimeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2), + PlaybackDiff: this.playbackCore.playbackDiffMillis.toFixed(2), + SyncMethod: this.syncMethod + }; + } + + /** + * Gets playback status. + * @returns {boolean} Whether a player is active. + */ + isPlaybackActive() { + return this.playerWrapper.isPlaybackActive(); + } + + /** + * Whether the player is remotely self-managed. + * @returns {boolean} _true_ if the player is remotely self-managed, _false_ otherwise. + */ + isRemote() { + return this.playerWrapper.isRemote(); + } + + /** + * Checks if playlist is empty. + * @returns {boolean} _true_ if playlist is empty, _false_ otherwise. + */ + isPlaylistEmpty() { + return this.queueCore.isPlaylistEmpty(); + } + + /** + * Checks if playback is unpaused. + * @returns {boolean} _true_ if media is playing, _false_ otherwise. + */ + isPlaying() { + if (!this.lastPlaybackCommand) { + return false; + } else { + return this.lastPlaybackCommand.Command === 'Unpause'; + } + } + + /** + * Emits an event to update the SyncPlay status icon. + */ + showSyncIcon(syncMethod) { + this.syncMethod = syncMethod; + Events.trigger(this, 'syncing', [true, this.syncMethod]); + } + + /** + * Emits an event to clear the SyncPlay status icon. + */ + clearSyncIcon() { + this.syncMethod = 'None'; + Events.trigger(this, 'syncing', [false, this.syncMethod]); + } +} + +export default Manager; diff --git a/src/components/syncPlay/core/PlaybackCore.js b/src/components/syncPlay/core/PlaybackCore.js new file mode 100644 index 00000000000..12e0c67abba --- /dev/null +++ b/src/components/syncPlay/core/PlaybackCore.js @@ -0,0 +1,587 @@ +/** + * Module that manages the playback of SyncPlay. + * @module components/syncPlay/core/PlaybackCore + */ + +import { Events } from 'jellyfin-apiclient'; +import * as Helper from './Helper'; + +/** + * Class that manages the playback of SyncPlay. + */ +class PlaybackCore { + constructor() { + this.manager = null; + this.timeSyncCore = null; + + this.syncEnabled = false; + this.playbackDiffMillis = 0; // Used for stats and remote time sync. + this.syncAttempts = 0; + this.lastSyncTime = new Date(); + this.enableSyncCorrection = true; // User setting to disable sync during playback. + + this.playerIsBuffering = false; + + this.lastCommand = null; // Last scheduled playback command, might not be the latest one. + this.scheduledCommandTimeout = null; + this.syncTimeout = null; + } + + /** + * Initializes the core. + * @param {Manager} syncPlayManager The SyncPlay manager. + */ + init(syncPlayManager) { + this.manager = syncPlayManager; + this.timeSyncCore = syncPlayManager.getTimeSyncCore(); + + // Minimum required delay for SpeedToSync to kick in, in milliseconds. + this.minDelaySpeedToSync = 60.0; + + // Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds. + this.maxDelaySpeedToSync = 3000.0; + + // Time during which the playback is sped up, in milliseconds. + this.speedToSyncDuration = 1000.0; + + // Minimum required delay for SkipToSync to kick in, in milliseconds. + this.minDelaySkipToSync = 400.0; + + // Whether SpeedToSync should be used. + this.useSpeedToSync = true; + + // Whether SkipToSync should be used. + this.useSkipToSync = true; + + // Whether sync correction during playback is active. + this.enableSyncCorrection = true; + } + + /** + * Called by player wrapper when playback starts. + */ + onPlaybackStart(player, state) { + Events.trigger(this.manager, 'playbackstart', [player, state]); + } + + /** + * Called by player wrapper when playback stops. + */ + onPlaybackStop(stopInfo) { + this.lastCommand = null; + Events.trigger(this.manager, 'playbackstop', [stopInfo]); + } + + /** + * Called by player wrapper when playback unpauses. + */ + onUnpause() { + Events.trigger(this.manager, 'unpause'); + } + + /** + * Called by player wrapper when playback pauses. + */ + onPause() { + Events.trigger(this.manager, 'pause'); + } + + /** + * Called by player wrapper on playback progress. + * @param {Object} event The time update event. + * @param {Object} timeUpdateData The time update data. + */ + onTimeUpdate(event, timeUpdateData) { + this.syncPlaybackTime(timeUpdateData); + Events.trigger(this.manager, 'timeupdate', [event, timeUpdateData]); + } + + /** + * Called by player wrapper when player is ready to play. + */ + onReady() { + this.playerIsBuffering = false; + this.sendBufferingRequest(false); + Events.trigger(this.manager, 'ready'); + } + + /** + * Called by player wrapper when player is buffering. + */ + onBuffering() { + this.playerIsBuffering = true; + this.sendBufferingRequest(true); + Events.trigger(this.manager, 'buffering'); + } + + /** + * Sends a buffering request to the server. + * @param {boolean} isBuffering Whether this client is buffering or not. + */ + sendBufferingRequest(isBuffering = true) { + const playerWrapper = this.manager.getPlayerWrapper(); + const currentPosition = playerWrapper.currentTime(); + const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond); + const isPlaying = playerWrapper.isPlaying(); + + const currentTime = new Date(); + const now = this.timeSyncCore.localDateToRemote(currentTime); + const playlistItemId = this.manager.getQueueCore().getCurrentPlaylistItemId(); + + const options = { + When: now.toISOString(), + PositionTicks: currentPositionTicks, + IsPlaying: isPlaying, + PlaylistItemId: playlistItemId + }; + + const apiClient = this.manager.getApiClient(); + if (isBuffering) { + apiClient.requestSyncPlayBuffering(options); + } else { + apiClient.requestSyncPlayReady(options); + } + } + + /** + * Gets playback buffering status. + * @returns {boolean} _true_ if player is buffering, _false_ otherwise. + */ + isBuffering() { + return this.playerIsBuffering; + } + + /** + * Applies a command and checks the playback state if a duplicate command is received. + * @param {Object} command The playback command. + */ + applyCommand(command) { + // Check if duplicate. + if (this.lastCommand && + this.lastCommand.When.getTime() === command.When.getTime() && + this.lastCommand.PositionTicks === command.PositionTicks && + this.lastCommand.Command === command.Command && + this.lastCommand.PlaylistItemId === command.PlaylistItemId + ) { + // Duplicate command found, check playback state and correct if needed. + console.debug('SyncPlay applyCommand: duplicate command received!', command); + + // Determine if past command or future one. + const currentTime = new Date(); + const whenLocal = this.timeSyncCore.remoteDateToLocal(command.When); + if (whenLocal > currentTime) { + // Command should be already scheduled, not much we can do. + // TODO: should re-apply or just drop? + console.debug('SyncPlay applyCommand: command already scheduled.', command); + return; + } else { + // Check if playback state matches requested command. + const playerWrapper = this.manager.getPlayerWrapper(); + const currentPositionTicks = Math.round(playerWrapper.currentTime() * Helper.TicksPerMillisecond); + const isPlaying = playerWrapper.isPlaying(); + + switch (command.Command) { + case 'Unpause': + // Check playback state only, as position ticks will be corrected by sync. + if (!isPlaying) { + this.scheduleUnpause(command.When, command.PositionTicks); + } + break; + case 'Pause': + // FIXME: check range instead of fixed value for ticks. + if (isPlaying || currentPositionTicks !== command.PositionTicks) { + this.schedulePause(command.When, command.PositionTicks); + } + break; + case 'Stop': + if (isPlaying) { + this.scheduleStop(command.When); + } + break; + case 'Seek': + // During seek, playback is paused. + // FIXME: check range instead of fixed value for ticks. + if (isPlaying || currentPositionTicks !== command.PositionTicks) { + // Account for player imperfections, we got half a second of tollerance we can play with + // (the server tollerates a range of values when client reports that is ready). + const rangeWidth = 100; // In milliseconds. + const randomOffsetTicks = Math.round((Math.random() - 0.5) * rangeWidth) * Helper.TicksPerMillisecond; + this.scheduleSeek(command.When, command.PositionTicks + randomOffsetTicks); + console.debug('SyncPlay applyCommand: adding random offset to force seek:', randomOffsetTicks, command); + } else { + // All done, I guess? + this.sendBufferingRequest(false); + } + break; + default: + console.error('SyncPlay applyCommand: command is not recognised:', command); + break; + } + + // All done. + return; + } + } + + // Applying command. + this.lastCommand = command; + + // Ignore if remote player has local SyncPlay manager. + if (this.manager.isRemote()) { + return; + } + + switch (command.Command) { + case 'Unpause': + this.scheduleUnpause(command.When, command.PositionTicks); + break; + case 'Pause': + this.schedulePause(command.When, command.PositionTicks); + break; + case 'Stop': + this.scheduleStop(command.When); + break; + case 'Seek': + this.scheduleSeek(command.When, command.PositionTicks); + break; + default: + console.error('SyncPlay applyCommand: command is not recognised:', command); + break; + } + } + + /** + * Schedules a resume playback on the player at the specified clock time. + * @param {Date} playAtTime The server's UTC time at which to resume playback. + * @param {number} positionTicks The PositionTicks from where to resume. + */ + scheduleUnpause(playAtTime, positionTicks) { + this.clearScheduledCommand(); + const enableSyncTimeout = this.maxDelaySpeedToSync / 2.0; + const currentTime = new Date(); + const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime); + + const playerWrapper = this.manager.getPlayerWrapper(); + const currentPositionTicks = playerWrapper.currentTime() * Helper.TicksPerMillisecond; + + if (playAtTimeLocal > currentTime) { + const playTimeout = playAtTimeLocal - currentTime; + + // Seek only if delay is noticeable. + if ((currentPositionTicks - positionTicks) > this.minDelaySkipToSync * Helper.TicksPerMillisecond) { + this.localSeek(positionTicks); + } + + this.scheduledCommandTimeout = setTimeout(() => { + this.localUnpause(); + Events.trigger(this.manager, 'notify-osd', ['unpause']); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + }, enableSyncTimeout); + }, playTimeout); + + console.debug('Scheduled unpause in', playTimeout / 1000.0, 'seconds.'); + } else { + // Group playback already started. + const serverPositionTicks = this.estimateCurrentTicks(positionTicks, playAtTime); + Helper.waitForEventOnce(this.manager, 'unpause').then(() => { + this.localSeek(serverPositionTicks); + }); + this.localUnpause(); + setTimeout(() => { + Events.trigger(this.manager, 'notify-osd', ['unpause']); + }, 100); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + }, enableSyncTimeout); + + console.debug(`SyncPlay scheduleUnpause: unpause now from ${serverPositionTicks} (was at ${currentPositionTicks}).`); + } + } + + /** + * Schedules a pause playback on the player at the specified clock time. + * @param {Date} pauseAtTime The server's UTC time at which to pause playback. + * @param {number} positionTicks The PositionTicks where player will be paused. + */ + schedulePause(pauseAtTime, positionTicks) { + this.clearScheduledCommand(); + const currentTime = new Date(); + const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime); + + const callback = () => { + Helper.waitForEventOnce(this.manager, 'pause', Helper.WaitForPlayerEventTimeout).then(() => { + this.localSeek(positionTicks); + }).catch(() => { + // Player was already paused, seeking. + this.localSeek(positionTicks); + }); + this.localPause(); + }; + + if (pauseAtTimeLocal > currentTime) { + const pauseTimeout = pauseAtTimeLocal - currentTime; + this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout); + + console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.'); + } else { + callback(); + console.debug('SyncPlay schedulePause: now.'); + } + } + + /** + * Schedules a stop playback on the player at the specified clock time. + * @param {Date} stopAtTime The server's UTC time at which to stop playback. + */ + scheduleStop(stopAtTime) { + this.clearScheduledCommand(); + const currentTime = new Date(); + const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime); + + const callback = () => { + this.localStop(); + }; + + if (stopAtTimeLocal > currentTime) { + const stopTimeout = stopAtTimeLocal - currentTime; + this.scheduledCommandTimeout = setTimeout(callback, stopTimeout); + + console.debug('Scheduled stop in', stopTimeout / 1000.0, 'seconds.'); + } else { + callback(); + console.debug('SyncPlay scheduleStop: now.'); + } + } + + /** + * Schedules a seek playback on the player at the specified clock time. + * @param {Date} seekAtTime The server's UTC time at which to seek playback. + * @param {number} positionTicks The PositionTicks where player will be seeked. + */ + scheduleSeek(seekAtTime, positionTicks) { + this.clearScheduledCommand(); + const currentTime = new Date(); + const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime); + + const callback = () => { + this.localUnpause(); + this.localSeek(positionTicks); + + Helper.waitForEventOnce(this.manager, 'ready', Helper.WaitForEventDefaultTimeout).then(() => { + this.localPause(); + this.sendBufferingRequest(false); + }).catch((error) => { + console.error(`Timed out while waiting for 'ready' event! Seeking to ${positionTicks}.`, error); + this.localSeek(positionTicks); + }); + }; + + if (seekAtTimeLocal > currentTime) { + const seekTimeout = seekAtTimeLocal - currentTime; + this.scheduledCommandTimeout = setTimeout(callback, seekTimeout); + + console.debug('Scheduled seek in', seekTimeout / 1000.0, 'seconds.'); + } else { + callback(); + console.debug('SyncPlay scheduleSeek: now.'); + } + } + + /** + * Clears the current scheduled command. + */ + clearScheduledCommand() { + clearTimeout(this.scheduledCommandTimeout); + clearTimeout(this.syncTimeout); + + this.syncEnabled = false; + const playerWrapper = this.manager.getPlayerWrapper(); + if (playerWrapper.hasPlaybackRate()) { + playerWrapper.setPlaybackRate(1.0); + } + + this.manager.clearSyncIcon(); + } + + /** + * Unpauses the local player. + */ + localUnpause() { + // Ignore command when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay localUnpause: no active player!'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + return playerWrapper.localUnpause(); + } + + /** + * Pauses the local player. + */ + localPause() { + // Ignore command when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay localPause: no active player!'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + return playerWrapper.localPause(); + } + + /** + * Seeks the local player. + */ + localSeek(positionTicks) { + // Ignore command when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay localSeek: no active player!'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + return playerWrapper.localSeek(positionTicks); + } + + /** + * Stops the local player. + */ + localStop() { + // Ignore command when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay localStop: no active player!'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + return playerWrapper.localStop(); + } + + /** + * Estimates current value for ticks given a past state. + * @param {number} ticks The value of the ticks. + * @param {Date} when The point in time for the value of the ticks. + * @param {Date} currentTime The current time, optional. + */ + estimateCurrentTicks(ticks, when, currentTime = new Date()) { + const remoteTime = this.timeSyncCore.localDateToRemote(currentTime); + return ticks + (remoteTime.getTime() - when.getTime()) * Helper.TicksPerMillisecond; + } + + /** + * Attempts to sync playback time with estimated server time (or selected device for time sync). + * + * When sync is enabled, the following will be checked: + * - check if local playback time is close enough to the server playback time; + * - playback diff (distance from estimated server playback time) is aligned with selected device for time sync. + * If playback diff exceeds some set thresholds, then a playback time sync will be attempted. + * Two strategies of syncing are available: + * - SpeedToSync: speeds up the media for some time to catch up (default is one second) + * - SkipToSync: seeks the media to the estimated correct time + * SpeedToSync aims to reduce the delay as much as possible, whereas SkipToSync is less pretentious. + * @param {Object} timeUpdateData The time update data that contains the current time as date and the current position in milliseconds. + */ + syncPlaybackTime(timeUpdateData) { + // See comments in constants section for more info. + const syncMethodThreshold = this.maxDelaySpeedToSync; + let speedToSyncTime = this.speedToSyncDuration; + + // Ignore sync when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay syncPlaybackTime: no active player!'); + return; + } + + // Attempt to sync only when media is playing. + const { lastCommand } = this; + + if (!lastCommand || lastCommand.Command !== 'Unpause' || this.isBuffering()) return; + + // Avoid spoilers by making sure that command item matches current playlist item. + // This check is needed when switching from one item to another. + const queueCore = this.manager.getQueueCore(); + const currentPlaylistItem = queueCore.getCurrentPlaylistItemId(); + if (lastCommand.PlaylistItemId !== currentPlaylistItem) return; + + const { currentTime, currentPosition } = timeUpdateData; + + // Get current PositionTicks. + const currentPositionTicks = currentPosition * Helper.TicksPerMillisecond; + + // Estimate PositionTicks on server. + const serverPositionTicks = this.estimateCurrentTicks(lastCommand.PositionTicks, lastCommand.When, currentTime); + + // Measure delay that needs to be recovered. + // Diff might be caused by the player internally starting the playback. + const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond; + + this.playbackDiffMillis = diffMillis; + + // Avoid overloading the browser. + const elapsed = currentTime - this.lastSyncTime; + if (elapsed < syncMethodThreshold / 2) return; + + this.lastSyncTime = currentTime; + const playerWrapper = this.manager.getPlayerWrapper(); + + if (this.syncEnabled && this.enableSyncCorrection) { + const absDiffMillis = Math.abs(diffMillis); + // TODO: SpeedToSync sounds bad on songs. + // TODO: SpeedToSync is failing on Safari (Mojave); even if playbackRate is supported, some delay seems to exist. + // TODO: both SpeedToSync and SpeedToSync seem to have a hard time keeping up on Android Chrome as well. + if (playerWrapper.hasPlaybackRate() && this.useSpeedToSync && absDiffMillis >= this.minDelaySpeedToSync && absDiffMillis < this.maxDelaySpeedToSync) { + // Fix negative speed when client is ahead of time more than speedToSyncTime. + const MinSpeed = 0.2; + if (diffMillis <= -speedToSyncTime * MinSpeed) { + speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed); + } + + // SpeedToSync strategy. + const speed = 1 + diffMillis / speedToSyncTime; + + if (speed <= 0) { + console.error('SyncPlay error: speed should not be negative!', speed, diffMillis, speedToSyncTime); + } + + playerWrapper.setPlaybackRate(speed); + this.syncEnabled = false; + this.syncAttempts++; + this.manager.showSyncIcon(`SpeedToSync (x${speed.toFixed(2)})`); + + this.syncTimeout = setTimeout(() => { + playerWrapper.setPlaybackRate(1.0); + this.syncEnabled = true; + this.manager.clearSyncIcon(); + }, speedToSyncTime); + + console.log('SyncPlay SpeedToSync', speed); + } else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) { + // SkipToSync strategy. + this.localSeek(serverPositionTicks); + this.syncEnabled = false; + this.syncAttempts++; + this.manager.showSyncIcon(`SkipToSync (${this.syncAttempts})`); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + this.manager.clearSyncIcon(); + }, syncMethodThreshold / 2); + + console.log('SyncPlay SkipToSync', serverPositionTicks); + } else { + // Playback is synced. + if (this.syncAttempts > 0) { + console.debug('Playback has been synced after', this.syncAttempts, 'attempts.'); + } + this.syncAttempts = 0; + } + } + } +} + +export default PlaybackCore; diff --git a/src/components/syncPlay/core/QueueCore.js b/src/components/syncPlay/core/QueueCore.js new file mode 100644 index 00000000000..ba9bb754fe0 --- /dev/null +++ b/src/components/syncPlay/core/QueueCore.js @@ -0,0 +1,371 @@ +/** + * Module that manages the queue of SyncPlay. + * @module components/syncPlay/core/QueueCore + */ + +import * as Helper from './Helper'; + +/** + * Class that manages the queue of SyncPlay. + */ +class QueueCore { + constructor() { + this.manager = null; + this.lastPlayQueueUpdate = null; + this.playlist = []; + } + + /** + * Initializes the core. + * @param {Manager} syncPlayManager The SyncPlay manager. + */ + init(syncPlayManager) { + this.manager = syncPlayManager; + } + + /** + * Handles the change in the play queue. + * @param {Object} apiClient The ApiClient. + * @param {Object} newPlayQueue The new play queue. + */ + updatePlayQueue(apiClient, newPlayQueue) { + newPlayQueue.LastUpdate = new Date(newPlayQueue.LastUpdate); + + if (newPlayQueue.LastUpdate.getTime() <= this.getLastUpdateTime()) { + console.debug('SyncPlay updatePlayQueue: ignoring old update', newPlayQueue); + return; + } + + console.debug('SyncPlay updatePlayQueue:', newPlayQueue); + + const serverId = apiClient.serverInfo().Id; + + this.onPlayQueueUpdate(apiClient, newPlayQueue, serverId).then((previous) => { + if (newPlayQueue.LastUpdate.getTime() < this.getLastUpdateTime()) { + console.warn('SyncPlay updatePlayQueue: trying to apply old update.', newPlayQueue); + throw new Error('Trying to apply old update'); + } + + // Ignore if remote player is self-managed (has own SyncPlay manager running). + if (this.manager.isRemote()) { + console.warn('SyncPlay updatePlayQueue: remote player has own SyncPlay manager.'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + + switch (newPlayQueue.Reason) { + case 'NewPlaylist': { + if (!this.manager.isFollowingGroupPlayback()) { + this.manager.followGroupPlayback(apiClient).then(() => { + this.startPlayback(apiClient); + }); + } else { + this.startPlayback(apiClient); + } + break; + } + case 'SetCurrentItem': + case 'NextItem': + case 'PreviousItem': { + playerWrapper.onQueueUpdate(); + + const playlistItemId = this.getCurrentPlaylistItemId(); + this.setCurrentPlaylistItem(apiClient, playlistItemId); + break; + } + case 'RemoveItems': { + playerWrapper.onQueueUpdate(); + + const index = previous.playQueueUpdate.PlayingItemIndex; + const oldPlaylistItemId = index === -1 ? null : previous.playlist[index].PlaylistItemId; + const playlistItemId = this.getCurrentPlaylistItemId(); + if (oldPlaylistItemId !== playlistItemId) { + this.setCurrentPlaylistItem(apiClient, playlistItemId); + } + break; + } + case 'MoveItem': + case 'Queue': + case 'QueueNext': { + playerWrapper.onQueueUpdate(); + break; + } + case 'RepeatMode': + playerWrapper.localSetRepeatMode(this.getRepeatMode()); + break; + case 'ShuffleMode': + playerWrapper.localSetQueueShuffleMode(this.getShuffleMode()); + break; + default: + console.error('SyncPlay updatePlayQueue: unknown reason for update:', newPlayQueue.Reason); + break; + } + }).catch((error) => { + console.warn('SyncPlay updatePlayQueue:', error); + }); + } + + /** + * Called when a play queue update needs to be applied. + * @param {Object} apiClient The ApiClient. + * @param {Object} playQueueUpdate The play queue update. + * @param {string} serverId The server identifier. + * @returns {Promise} A promise that gets resolved when update is applied. + */ + onPlayQueueUpdate(apiClient, playQueueUpdate, serverId) { + const oldPlayQueueUpdate = this.lastPlayQueueUpdate; + const oldPlaylist = this.playlist; + + const itemIds = playQueueUpdate.Playlist.map(queueItem => queueItem.ItemId); + + if (!itemIds.length) { + if (this.lastPlayQueueUpdate && playQueueUpdate.LastUpdate.getTime() <= this.getLastUpdateTime()) { + return Promise.reject('Trying to apply old update'); + } + + this.lastPlayQueueUpdate = playQueueUpdate; + this.playlist = []; + + return Promise.resolve({ + playQueueUpdate: oldPlayQueueUpdate, + playlist: oldPlaylist + }); + } + + return Helper.getItemsForPlayback(apiClient, { + Ids: itemIds.join(',') + }).then((result) => { + return Helper.translateItemsForPlayback(apiClient, result.Items, { + ids: itemIds, + serverId: serverId + }).then((items) => { + if (this.lastPlayQueueUpdate && playQueueUpdate.LastUpdate.getTime() <= this.getLastUpdateTime()) { + throw new Error('Trying to apply old update'); + } + + for (let i = 0; i < items.length; i++) { + items[i].PlaylistItemId = playQueueUpdate.Playlist[i].PlaylistItemId; + } + + this.lastPlayQueueUpdate = playQueueUpdate; + this.playlist = items; + + return { + playQueueUpdate: oldPlayQueueUpdate, + playlist: oldPlaylist + }; + }); + }); + } + + /** + * Sends a SyncPlayBuffering request on playback start. + * @param {Object} apiClient The ApiClient. + * @param {string} origin The origin of the wait call, used for debug. + */ + scheduleReadyRequestOnPlaybackStart(apiClient, origin) { + Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(() => { + console.debug('SyncPlay scheduleReadyRequestOnPlaybackStart: local pause and notify server.'); + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localPause(); + + const currentTime = new Date(); + const now = this.manager.timeSyncCore.localDateToRemote(currentTime); + const currentPosition = playerWrapper.currentTime(); + const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond); + const isPlaying = playerWrapper.isPlaying(); + + apiClient.requestSyncPlayReady({ + When: now.toISOString(), + PositionTicks: currentPositionTicks, + IsPlaying: isPlaying, + PlaylistItemId: this.getCurrentPlaylistItemId() + }); + }).catch((error) => { + console.error('Error while waiting for `playbackstart` event!', origin, error); + if (!this.manager.isSyncPlayEnabled()) { + Helper.showMessage(this.manager, 'MessageSyncPlayErrorMedia'); + } + + this.manager.haltGroupPlayback(apiClient); + return; + }); + } + + /** + * Prepares this client for playback by loading the group's content. + * @param {Object} apiClient The ApiClient. + */ + startPlayback(apiClient) { + if (!this.manager.isFollowingGroupPlayback()) { + console.debug('SyncPlay startPlayback: ignoring, not following playback.'); + return Promise.reject(); + } + + if (this.isPlaylistEmpty()) { + console.debug('SyncPlay startPlayback: empty playlist.'); + return; + } + + // Estimate start position ticks from last playback command, if available. + const playbackCommand = this.manager.getLastPlaybackCommand(); + let startPositionTicks = 0; + + if (playbackCommand && playbackCommand.EmittedAt.getTime() >= this.getLastUpdateTime()) { + // Prefer playback commands as they're more frequent (and also because playback position is PlaybackCore's concern). + startPositionTicks = this.manager.getPlaybackCore().estimateCurrentTicks(playbackCommand.PositionTicks, playbackCommand.When); + } else { + // A PlayQueueUpdate is emited only on queue changes so it's less reliable for playback position syncing. + const oldStartPositionTicks = this.getStartPositionTicks(); + const lastQueueUpdateDate = this.getLastUpdate(); + startPositionTicks = this.manager.getPlaybackCore().estimateCurrentTicks(oldStartPositionTicks, lastQueueUpdateDate); + } + + const serverId = apiClient.serverInfo().Id; + + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localPlay({ + ids: this.getPlaylistAsItemIds(), + startPositionTicks: startPositionTicks, + startIndex: this.getCurrentPlaylistIndex(), + serverId: serverId + }).then(() => { + this.scheduleReadyRequestOnPlaybackStart(apiClient, 'startPlayback'); + }).catch((error) => { + console.error(error); + Helper.showMessage(this.manager, 'MessageSyncPlayErrorMedia'); + }); + } + + /** + * Sets the current playing item. + * @param {Object} apiClient The ApiClient. + * @param {string} playlistItemId The playlist id of the item to play. + */ + setCurrentPlaylistItem(apiClient, playlistItemId) { + if (!this.manager.isFollowingGroupPlayback()) { + console.debug('SyncPlay setCurrentPlaylistItem: ignoring, not following playback.'); + return; + } + + this.scheduleReadyRequestOnPlaybackStart(apiClient, 'setCurrentPlaylistItem'); + + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localSetCurrentPlaylistItem(playlistItemId); + } + + /** + * Gets the index of the current playing item. + * @returns {number} The index of the playing item. + */ + getCurrentPlaylistIndex() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.PlayingItemIndex; + } else { + return -1; + } + } + + /** + * Gets the playlist item id of the playing item. + * @returns {string} The playlist item id. + */ + getCurrentPlaylistItemId() { + if (this.lastPlayQueueUpdate) { + const index = this.lastPlayQueueUpdate.PlayingItemIndex; + return index === -1 ? null : this.playlist[index].PlaylistItemId; + } else { + return null; + } + } + + /** + * Gets a copy of the playlist. + * @returns {Array} The playlist. + */ + getPlaylist() { + return this.playlist.slice(0); + } + + /** + * Checks if playlist is empty. + * @returns {boolean} _true_ if playlist is empty, _false_ otherwise. + */ + isPlaylistEmpty() { + return this.playlist.length === 0; + } + + /** + * Gets the last update time as date, if any. + * @returns {Date} The date. + */ + getLastUpdate() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.LastUpdate; + } else { + return null; + } + } + + /** + * Gets the time of when the queue has been updated. + * @returns {number} The last update time. + */ + getLastUpdateTime() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.LastUpdate.getTime(); + } else { + return 0; + } + } + + /** + * Gets the last reported start position ticks of playing item. + * @returns {number} The start position ticks. + */ + getStartPositionTicks() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.StartPositionTicks; + } else { + return 0; + } + } + + /** + * Gets the list of item identifiers in the playlist. + * @returns {Array} The list of items. + */ + getPlaylistAsItemIds() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.Playlist.map(queueItem => queueItem.ItemId); + } else { + return []; + } + } + + /** + * Gets the repeat mode. + * @returns {string} The repeat mode. + */ + getRepeatMode() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.RepeatMode; + } else { + return 'Sorted'; + } + } + /** + * Gets the shuffle mode. + * @returns {string} The shuffle mode. + */ + getShuffleMode() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.ShuffleMode; + } else { + return 'RepeatNone'; + } + } +} + +export default QueueCore; diff --git a/src/components/syncPlay/core/index.js b/src/components/syncPlay/core/index.js new file mode 100644 index 00000000000..ca07e9b3615 --- /dev/null +++ b/src/components/syncPlay/core/index.js @@ -0,0 +1,16 @@ +import * as Helper from './Helper'; +import ManagerClass from './Manager'; +import PlayerFactoryClass from './players/PlayerFactory'; +import GenericPlayer from './players/GenericPlayer'; + +const PlayerFactory = new PlayerFactoryClass(); +const Manager = new ManagerClass(PlayerFactory); + +export default { + Helper, + Manager, + PlayerFactory, + Players: { + GenericPlayer + } +}; diff --git a/src/components/syncPlay/core/players/GenericPlayer.js b/src/components/syncPlay/core/players/GenericPlayer.js new file mode 100644 index 00000000000..ac922387b80 --- /dev/null +++ b/src/components/syncPlay/core/players/GenericPlayer.js @@ -0,0 +1,305 @@ +/** + * Module that translates events from a player to SyncPlay events. + * @module components/syncPlay/core/players/GenericPlayer + */ + +import { Events } from 'jellyfin-apiclient'; + +/** + * Class that translates events from a player to SyncPlay events. + */ +class GenericPlayer { + static type = 'generic'; + + constructor(player, syncPlayManager) { + this.player = player; + this.manager = syncPlayManager; + this.playbackCore = syncPlayManager.getPlaybackCore(); + this.queueCore = syncPlayManager.getQueueCore(); + this.bound = false; + } + + /** + * Binds to the player's events. + */ + bindToPlayer() { + if (this.bound) { + return; + } + + this.localBindToPlayer(); + this.bound = true; + } + + /** + * Binds to the player's events. Overriden. + */ + localBindToPlayer() { + throw new Error('Override this method!'); + } + + /** + * Removes the bindings from the player's events. + */ + unbindFromPlayer() { + if (!this.bound) { + return; + } + + this.localUnbindFromPlayer(); + this.bound = false; + } + + /** + * Removes the bindings from the player's events. Overriden. + */ + localUnbindFromPlayer() { + throw new Error('Override this method!'); + } + + /** + * Called when playback starts. + */ + onPlaybackStart(player, state) { + this.playbackCore.onPlaybackStart(player, state); + Events.trigger(this, 'playbackstart', [player, state]); + } + + /** + * Called when playback stops. + */ + onPlaybackStop(stopInfo) { + this.playbackCore.onPlaybackStop(stopInfo); + Events.trigger(this, 'playbackstop', [stopInfo]); + } + + /** + * Called when playback unpauses. + */ + onUnpause() { + this.playbackCore.onUnpause(); + Events.trigger(this, 'unpause', [this.currentPlayer]); + } + + /** + * Called when playback pauses. + */ + onPause() { + this.playbackCore.onPause(); + Events.trigger(this, 'pause', [this.currentPlayer]); + } + + /** + * Called on playback progress. + * @param {Object} event The time update event. + * @param {Object} timeUpdateData The time update data. + */ + onTimeUpdate(event, timeUpdateData) { + this.playbackCore.onTimeUpdate(event, timeUpdateData); + Events.trigger(this, 'timeupdate', [event, timeUpdateData]); + } + + /** + * Called when player is ready to resume playback. + */ + onReady() { + this.playbackCore.onReady(); + Events.trigger(this, 'ready'); + } + + /** + * Called when player is buffering. + */ + onBuffering() { + this.playbackCore.onBuffering(); + Events.trigger(this, 'buffering'); + } + + /** + * Called when changes are made to the play queue. + */ + onQueueUpdate() { + // Do nothing. + } + + /** + * Gets player status. + * @returns {boolean} Whether the player has some media loaded. + */ + isPlaybackActive() { + return false; + } + + /** + * Gets playback status. + * @returns {boolean} Whether the playback is unpaused. + */ + isPlaying() { + return false; + } + + /** + * Gets playback position. + * @returns {number} The player position, in milliseconds. + */ + currentTime() { + return 0; + } + + /** + * Checks if player has playback rate support. + * @returns {boolean} _true _ if playback rate is supported, false otherwise. + */ + hasPlaybackRate() { + return false; + } + + /** + * Sets the playback rate, if supported. + * @param {number} value The playback rate. + */ + setPlaybackRate(value) { + // Do nothing. + } + + /** + * Gets the playback rate. + * @returns {number} The playback rate. + */ + getPlaybackRate() { + return 1.0; + } + + /** + * Checks if player is remotely self-managed. + * @returns {boolean} _true_ if the player is remotely self-managed, _false_ otherwise. + */ + isRemote() { + return false; + } + + /** + * Unpauses the player. + */ + localUnpause() { + + } + + /** + * Pauses the player. + */ + localPause() { + + } + + /** + * Seeks the player to the specified position. + * @param {number} positionTicks The new position. + */ + localSeek(positionTicks) { + + } + + /** + * Stops the player. + */ + localStop() { + + } + + /** + * Sends a command to the player. + * @param {Object} command The command. + */ + localSendCommand(command) { + + } + + /** + * Starts playback. + * @param {Object} options Playback data. + */ + localPlay(options) { + + } + + /** + * Sets playing item from playlist. + * @param {string} playlistItemId The item to play. + */ + localSetCurrentPlaylistItem(playlistItemId) { + + } + + /** + * Removes items from playlist. + * @param {Array} playlistItemIds The items to remove. + */ + localRemoveFromPlaylist(playlistItemIds) { + + } + + /** + * Moves an item in the playlist. + * @param {string} playlistItemId The item to move. + * @param {number} newIndex The new position. + */ + localMovePlaylistItem(playlistItemId, newIndex) { + + } + + /** + * Queues in the playlist. + * @param {Object} options Queue data. + */ + localQueue(options) { + + } + + /** + * Queues after the playing item in the playlist. + * @param {Object} options Queue data. + */ + localQueueNext(options) { + + } + + /** + * Picks next item in playlist. + */ + localNextItem() { + + } + + /** + * Picks previous item in playlist. + */ + localPreviousItem() { + + } + + /** + * Sets repeat mode. + * @param {string} value The repeat mode. + */ + localSetRepeatMode(value) { + + } + + /** + * Sets shuffle mode. + * @param {string} value The shuffle mode. + */ + localSetQueueShuffleMode(value) { + + } + + /** + * Toggles shuffle mode. + */ + localToggleQueueShuffleMode() { + + } +} + +export default GenericPlayer; diff --git a/src/components/syncPlay/core/players/PlayerFactory.js b/src/components/syncPlay/core/players/PlayerFactory.js new file mode 100644 index 00000000000..709680cf636 --- /dev/null +++ b/src/components/syncPlay/core/players/PlayerFactory.js @@ -0,0 +1,71 @@ +/** + * Module that creates wrappers for known players. + * @module components/syncPlay/core/players/PlayerFactory + */ + +import GenericPlayer from './GenericPlayer'; + +/** + * Class that creates wrappers for known players. + */ +class PlayerFactory { + constructor() { + this.wrappers = {}; + this.DefaultWrapper = GenericPlayer; + } + + /** + * Registers a wrapper to the list of players that can be managed. + * @param {GenericPlayer} wrapperClass The wrapper to register. + */ + registerWrapper(wrapperClass) { + console.debug('SyncPlay WrapperFactory registerWrapper:', wrapperClass.type); + this.wrappers[wrapperClass.type] = wrapperClass; + } + + /** + * Sets the default player wrapper. + * @param {GenericPlayer} wrapperClass The wrapper. + */ + setDefaultWrapper(wrapperClass) { + console.debug('SyncPlay WrapperFactory setDefaultWrapper:', wrapperClass.type); + this.DefaultWrapper = wrapperClass; + } + + /** + * Gets a player wrapper that manages the given player. Default wrapper is used for unknown players. + * @param {Object} player The player to handle. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + * @returns The player wrapper. + */ + getWrapper(player, syncPlayManager) { + if (!player) { + console.debug('SyncPlay WrapperFactory getWrapper: using default wrapper.'); + return this.getDefaultWrapper(syncPlayManager); + } + + console.debug('SyncPlay WrapperFactory getWrapper:', player.id); + const Wrapper = this.wrappers[player.id]; + if (Wrapper) { + return new Wrapper(player, syncPlayManager); + } + + console.debug(`SyncPlay WrapperFactory getWrapper: unknown player ${player.id}, using default wrapper.`); + return this.getDefaultWrapper(syncPlayManager); + } + + /** + * Gets the default player wrapper. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + * @returns The default player wrapper. + */ + getDefaultWrapper(syncPlayManager) { + if (this.DefaultWrapper) { + return new this.DefaultWrapper(null, syncPlayManager); + } else { + return null; + } + } +} + +export default PlayerFactory; diff --git a/src/components/syncPlay/timeSyncManager.js b/src/components/syncPlay/core/timeSync/TimeSync.js similarity index 53% rename from src/components/syncPlay/timeSyncManager.js rename to src/components/syncPlay/core/timeSync/TimeSync.js index 78c160824d4..8c07986de11 100644 --- a/src/components/syncPlay/timeSyncManager.js +++ b/src/components/syncPlay/core/timeSync/TimeSync.js @@ -1,13 +1,12 @@ /** - * Module that manages time syncing with server. - * @module components/syncPlay/timeSyncManager + * Module that manages time syncing with another device. + * @module components/syncPlay/core/timeSync/TimeSync */ import { Events } from 'jellyfin-apiclient'; -import ServerConnections from '../ServerConnections'; /** - * Time estimation + * Time estimation. */ const NumberOfTrackedMeasurements = 8; const PollingIntervalGreedy = 1000; // milliseconds @@ -21,8 +20,8 @@ class Measurement { /** * Creates a new measurement. * @param {Date} requestSent Client's timestamp of the request transmission - * @param {Date} requestReceived Server's timestamp of the request reception - * @param {Date} responseSent Server's timestamp of the response transmission + * @param {Date} requestReceived Remote's timestamp of the request reception + * @param {Date} responseSent Remote's timestamp of the response transmission * @param {Date} responseReceived Client's timestamp of the response reception */ constructor(requestSent, requestReceived, responseSent, responseReceived) { @@ -33,32 +32,33 @@ class Measurement { } /** - * Time offset from server. + * Time offset from remote entity, in milliseconds. */ - getOffset () { + getOffset() { return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2; } /** - * Get round-trip delay. + * Get round-trip delay, in milliseconds. */ - getDelay () { + getDelay() { return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived); } /** - * Get ping time. + * Get ping time, in milliseconds. */ - getPing () { + getPing() { return this.getDelay() / 2; } } /** - * Class that manages time syncing with server. + * Class that manages time syncing with remote entity. */ -class TimeSyncManager { - constructor() { +class TimeSync { + constructor(syncPlayManager) { + this.manager = syncPlayManager; this.pingStop = true; this.pollingInterval = PollingIntervalGreedy; this.poller = null; @@ -76,23 +76,23 @@ class TimeSyncManager { } /** - * Gets time offset with server. + * Gets time offset with remote entity, in milliseconds. * @returns {number} The time offset. */ - getTimeOffset () { + getTimeOffset() { return this.measurement ? this.measurement.getOffset() : 0; } /** - * Gets ping time to server. + * Gets ping time to remote entity, in milliseconds. * @returns {number} The ping time. */ - getPing () { + getPing() { return this.measurement ? this.measurement.getPing() : 0; } /** - * Updates time offset between server and client. + * Updates time offset between remote entity and local entity. * @param {Measurement} measurement The new measurement. */ updateTimeOffset(measurement) { @@ -101,53 +101,68 @@ class TimeSyncManager { this.measurements.shift(); } - // Pick measurement with minimum delay + // Pick measurement with minimum delay. const sortedMeasurements = this.measurements.slice(0); sortedMeasurements.sort((a, b) => a.getDelay() - b.getDelay()); this.measurement = sortedMeasurements[0]; } /** - * Schedules a ping request to the server. Triggers time offset update. + * Schedules a ping request to the remote entity. Triggers time offset update. + * @returns {Promise} Resolves on request success. */ requestPing() { - if (!this.poller) { + console.warn('SyncPlay TimeSync requestPing: override this method!'); + return Promise.reject('Not implemented.'); + } + + /** + * Poller for ping requests. + */ + internalRequestPing() { + if (!this.poller && !this.pingStop) { this.poller = setTimeout(() => { this.poller = null; - const apiClient = ServerConnections.currentApiClient(); - const requestSent = new Date(); - apiClient.getServerTime().then((response) => { - const responseReceived = new Date(); - response.json().then((data) => { - const requestReceived = new Date(data.RequestReceptionTime); - const responseSent = new Date(data.ResponseTransmissionTime); - - const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived); - this.updateTimeOffset(measurement); - - // Avoid overloading server - if (this.pings >= GreedyPingCount) { - this.pollingInterval = PollingIntervalLowProfile; - } else { - this.pings++; - } - - Events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]); - }); - }).catch((error) => { - console.error(error); - Events.trigger(this, 'update', [error, null, null]); - }).finally(() => { - this.requestPing(); - }); + this.requestPing() + .then((result) => this.onPingResponseCallback(result)) + .catch((error) => this.onPingRequestErrorCallback(error)) + .finally(() => this.internalRequestPing()); }, this.pollingInterval); } } + /** + * Handles a successful ping request. + * @param {Object} result The ping result. + */ + onPingResponseCallback(result) { + const { requestSent, requestReceived, responseSent, responseReceived } = result; + const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived); + this.updateTimeOffset(measurement); + + // Avoid overloading network. + if (this.pings >= GreedyPingCount) { + this.pollingInterval = PollingIntervalLowProfile; + } else { + this.pings++; + } + + Events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]); + } + + /** + * Handles a failed ping request. + * @param {Object} error The error. + */ + onPingRequestErrorCallback(error) { + console.error(error); + Events.trigger(this, 'update', [error, null, null]); + } + /** * Drops accumulated measurements. */ - resetMeasurements () { + resetMeasurements() { this.measurement = null; this.measurements = []; } @@ -156,13 +171,15 @@ class TimeSyncManager { * Starts the time poller. */ startPing() { - this.requestPing(); + this.pingStop = false; + this.internalRequestPing(); } /** * Stops the time poller. */ stopPing() { + this.pingStop = true; if (this.poller) { clearTimeout(this.poller); this.poller = null; @@ -180,25 +197,24 @@ class TimeSyncManager { } /** - * Converts server time to local time. - * @param {Date} server The time to convert. + * Converts remote time to local time. + * @param {Date} remote The time to convert. * @returns {Date} Local time. */ - serverDateToLocal(server) { - // server - local = offset - return new Date(server.getTime() - this.getTimeOffset()); + remoteDateToLocal(remote) { + // remote - local = offset + return new Date(remote.getTime() - this.getTimeOffset()); } /** - * Converts local time to server time. + * Converts local time to remote time. * @param {Date} local The time to convert. - * @returns {Date} Server time. + * @returns {Date} Remote time. */ - localDateToServer(local) { - // server - local = offset + localDateToRemote(local) { + // remote - local = offset return new Date(local.getTime() + this.getTimeOffset()); } } -/** TimeSyncManager singleton. */ -export default new TimeSyncManager(); +export default TimeSync; diff --git a/src/components/syncPlay/core/timeSync/TimeSyncCore.js b/src/components/syncPlay/core/timeSync/TimeSyncCore.js new file mode 100644 index 00000000000..a67752648d8 --- /dev/null +++ b/src/components/syncPlay/core/timeSync/TimeSyncCore.js @@ -0,0 +1,78 @@ +/** + * Module that manages time syncing with several devices. + * @module components/syncPlay/core/timeSync/TimeSyncCore + */ + +import { Events } from 'jellyfin-apiclient'; +import TimeSyncServer from './TimeSyncServer'; + +/** + * Class that manages time syncing with several devices. + */ +class TimeSyncCore { + constructor() { + this.manager = null; + this.timeSyncServer = null; + } + + /** + * Initializes the core. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + */ + init(syncPlayManager) { + this.manager = syncPlayManager; + this.timeSyncServer = new TimeSyncServer(syncPlayManager); + + Events.on(this.timeSyncServer, 'update', (event, error, timeOffset, ping) => { + if (error) { + console.debug('SyncPlay TimeSyncCore: time sync with server issue:', error); + return; + } + + Events.trigger(this, 'time-sync-server-update', [timeOffset, ping]); + }); + } + + /** + * Forces time update with server. + */ + forceUpdate() { + this.timeSyncServer.forceUpdate(); + } + + /** + * Gets the display name of the selected device for time sync. + * @returns {string} The display name. + */ + getActiveDeviceName() { + return 'Server'; + } + + /** + * Converts server time to local time. + * @param {Date} remote The time to convert. + * @returns {Date} Local time. + */ + remoteDateToLocal(remote) { + return this.timeSyncServer.remoteDateToLocal(remote); + } + + /** + * Converts local time to server time. + * @param {Date} local The time to convert. + * @returns {Date} Server time. + */ + localDateToRemote(local) { + return this.timeSyncServer.localDateToRemote(local); + } + + /** + * Gets time offset that should be used for time syncing, in milliseconds. + * @returns {number} The time offset. + */ + getTimeOffset() { + return this.timeSyncServer.getTimeOffset(); + } +} + +export default TimeSyncCore; diff --git a/src/components/syncPlay/core/timeSync/TimeSyncServer.js b/src/components/syncPlay/core/timeSync/TimeSyncServer.js new file mode 100644 index 00000000000..734763c07db --- /dev/null +++ b/src/components/syncPlay/core/timeSync/TimeSyncServer.js @@ -0,0 +1,39 @@ +/** + * Module that manages time syncing with server. + * @module components/syncPlay/core/timeSync/TimeSyncServer + */ + +import TimeSync from './TimeSync'; + +/** + * Class that manages time syncing with server. + */ +class TimeSyncServer extends TimeSync { + constructor(syncPlayManager) { + super(syncPlayManager); + } + + /** + * Makes a ping request to the server. + */ + requestPing() { + const apiClient = this.manager.getApiClient(); + const requestSent = new Date(); + let responseReceived; + return apiClient.getServerTime().then((response) => { + responseReceived = new Date(); + return response.json(); + }).then((data) => { + const requestReceived = new Date(data.RequestReceptionTime); + const responseSent = new Date(data.ResponseTransmissionTime); + return Promise.resolve({ + requestSent: requestSent, + requestReceived: requestReceived, + responseSent: responseSent, + responseReceived: responseReceived + }); + }); + } +} + +export default TimeSyncServer; diff --git a/src/components/syncPlay/groupSelectionMenu.js b/src/components/syncPlay/groupSelectionMenu.js deleted file mode 100644 index 5168558bb22..00000000000 --- a/src/components/syncPlay/groupSelectionMenu.js +++ /dev/null @@ -1,189 +0,0 @@ -import { Events } from 'jellyfin-apiclient'; -import { playbackManager } from '../playback/playbackmanager'; -import syncPlayManager from './syncPlayManager'; -import loading from '../loading/loading'; -import toast from '../toast/toast'; -import actionsheet from '../actionSheet/actionSheet'; -import globalize from '../../scripts/globalize'; -import playbackPermissionManager from './playbackPermissionManager'; -import ServerConnections from '../ServerConnections'; - -/** - * Gets active player id. - * @returns {string} The player's id. - */ -function getActivePlayerId () { - const info = playbackManager.getPlayerInfo(); - return info ? info.id : null; -} - -/** - * Used when user needs to join a group. - * @param {HTMLElement} button - Element where to place the menu. - * @param {Object} user - Current user. - * @param {Object} apiClient - ApiClient. - */ -function showNewJoinGroupSelection (button, user, apiClient) { - const sessionId = getActivePlayerId() || 'none'; - const inSession = sessionId !== 'none'; - const policy = user.localUser ? user.localUser.Policy : {}; - let playingItemId; - try { - const playState = playbackManager.getPlayerState(); - playingItemId = playState.NowPlayingItem.Id; - console.debug('Item', playingItemId, 'is currently playing.'); - } catch (error) { - playingItemId = ''; - console.debug('No item is currently playing.'); - } - - apiClient.getSyncPlayGroups().then(function (response) { - response.json().then(function (groups) { - const menuItems = groups.map(function (group) { - return { - name: group.PlayingItemName, - icon: 'group', - id: group.GroupId, - selected: false, - secondaryText: group.Participants.join(', ') - }; - }); - - if (inSession && policy.SyncPlayAccess === 'CreateAndJoinGroups') { - menuItems.push({ - name: globalize.translate('LabelSyncPlayNewGroup'), - icon: 'add', - id: 'new-group', - selected: true, - secondaryText: globalize.translate('LabelSyncPlayNewGroupDescription') - }); - } - - if (menuItems.length === 0) { - if (inSession && policy.SyncPlayAccess === 'JoinGroups') { - toast({ - text: globalize.translate('MessageSyncPlayCreateGroupDenied') - }); - } else { - toast({ - text: globalize.translate('MessageSyncPlayNoGroupsAvailable') - }); - } - loading.hide(); - return; - } - - const menuOptions = { - title: globalize.translate('HeaderSyncPlaySelectGroup'), - items: menuItems, - positionTo: button, - resolveOnClick: true, - border: true - }; - - actionsheet.show(menuOptions).then(function (id) { - if (id == 'new-group') { - apiClient.createSyncPlayGroup(); - } else if (id) { - apiClient.joinSyncPlayGroup({ - GroupId: id, - PlayingItemId: playingItemId - }); - } - }).catch((error) => { - console.error('SyncPlay: unexpected error listing groups:', error); - }); - - loading.hide(); - }); - }).catch(function (error) { - console.error(error); - loading.hide(); - toast({ - text: globalize.translate('MessageSyncPlayErrorAccessingGroups') - }); - }); -} - -/** - * Used when user has joined a group. - * @param {HTMLElement} button - Element where to place the menu. - * @param {Object} user - Current user. - * @param {Object} apiClient - ApiClient. - */ -function showLeaveGroupSelection (button, user, apiClient) { - const sessionId = getActivePlayerId(); - if (!sessionId) { - syncPlayManager.signalError(); - toast({ - text: globalize.translate('MessageSyncPlayErrorNoActivePlayer') - }); - showNewJoinGroupSelection(button, user, apiClient); - return; - } - - const menuItems = [{ - name: globalize.translate('LabelSyncPlayLeaveGroup'), - icon: 'meeting_room', - id: 'leave-group', - selected: true, - secondaryText: globalize.translate('LabelSyncPlayLeaveGroupDescription') - }]; - - const menuOptions = { - title: globalize.translate('HeaderSyncPlayEnabled'), - items: menuItems, - positionTo: button, - resolveOnClick: true, - border: true - }; - - actionsheet.show(menuOptions).then(function (id) { - if (id == 'leave-group') { - apiClient.leaveSyncPlayGroup(); - } - }).catch((error) => { - console.error('SyncPlay: unexpected error showing group menu:', error); - }); - - loading.hide(); -} - -// Register to SyncPlay events -let syncPlayEnabled = false; -Events.on(syncPlayManager, 'enabled', function (e, enabled) { - syncPlayEnabled = enabled; -}); - -/** - * Shows a menu to handle SyncPlay groups. - * @param {HTMLElement} button - Element where to place the menu. - */ -export function show (button) { - loading.show(); - - // TODO: should feature be disabled if playback permission is missing? - playbackPermissionManager.check().then(() => { - console.debug('Playback is allowed.'); - }).catch((error) => { - console.error('Playback not allowed!', error); - toast({ - text: globalize.translate('MessageSyncPlayPlaybackPermissionRequired') - }); - }); - - const apiClient = ServerConnections.currentApiClient(); - ServerConnections.user(apiClient).then((user) => { - if (syncPlayEnabled) { - showLeaveGroupSelection(button, user, apiClient); - } else { - showNewJoinGroupSelection(button, user, apiClient); - } - }).catch((error) => { - console.error(error); - loading.hide(); - toast({ - text: globalize.translate('MessageSyncPlayNoGroupsAvailable') - }); - }); -} diff --git a/src/components/syncPlay/syncPlayManager.js b/src/components/syncPlay/syncPlayManager.js deleted file mode 100644 index 2db5f3817b9..00000000000 --- a/src/components/syncPlay/syncPlayManager.js +++ /dev/null @@ -1,838 +0,0 @@ -/** - * Module that manages the SyncPlay feature. - * @module components/syncPlay/syncPlayManager - */ - -import { Events } from 'jellyfin-apiclient'; -import { playbackManager } from '../playback/playbackmanager'; -import timeSyncManager from './timeSyncManager'; -import toast from '../toast/toast'; -import globalize from '../../scripts/globalize'; -import ServerConnections from '../ServerConnections'; - -/** - * Waits for an event to be triggered on an object. An optional timeout can specified after which the promise is rejected. - * @param {Object} emitter Object on which to listen for events. - * @param {string} eventType Event name to listen for. - * @param {number} timeout Time in milliseconds before rejecting promise if event does not trigger. - * @returns {Promise} A promise that resolves when the event is triggered. - */ -function waitForEventOnce(emitter, eventType, timeout) { - return new Promise((resolve, reject) => { - let rejectTimeout; - if (timeout) { - rejectTimeout = setTimeout(() => { - reject('Timed out.'); - }, timeout); - } - const callback = () => { - Events.off(emitter, eventType, callback); - if (rejectTimeout) { - clearTimeout(rejectTimeout); - } - resolve(arguments); - }; - Events.on(emitter, eventType, callback); - }); -} - -/** - * Gets active player id. - * @returns {string} The player's id. - */ -function getActivePlayerId() { - const info = playbackManager.getPlayerInfo(); - return info ? info.id : null; -} - -/** - * Playback synchronization - */ -const MaxAcceptedDelaySpeedToSync = 50; // milliseconds, delay after which SpeedToSync is enabled -const MaxAcceptedDelaySkipToSync = 300; // milliseconds, delay after which SkipToSync is enabled -const SyncMethodThreshold = 2000; // milliseconds, switches between SpeedToSync or SkipToSync -const SpeedToSyncTime = 1000; // milliseconds, duration in which the playback is sped up -const MaxAttemptsSpeedToSync = 3; // attempts before disabling SpeedToSync -const MaxAttemptsSync = 5; // attempts before disabling syncing at all - -/** - * Other constants - */ -const WaitForEventDefaultTimeout = 30000; // milliseconds -const WaitForPlayerEventTimeout = 500; // milliseconds - -/** - * Class that manages the SyncPlay feature. - */ -class SyncPlayManager { - constructor() { - this.playbackRateSupported = false; - this.syncEnabled = false; - this.playbackDiffMillis = 0; // used for stats - this.syncMethod = 'None'; // used for stats - this.syncAttempts = 0; - this.lastSyncTime = new Date(); - this.syncWatcherTimeout = null; // interval that watches playback time and syncs it - - this.lastPlaybackWaiting = null; // used to determine if player's buffering - this.minBufferingThresholdMillis = 1000; - - this.currentPlayer = null; - this.localPlayerPlaybackRate = 1.0; // used to restore user PlaybackRate - - this.syncPlayEnabledAt = null; // Server time of when SyncPlay has been enabled - this.syncPlayReady = false; // SyncPlay is ready after first ping to server - - this.lastCommand = null; - this.queuedCommand = null; - - this.scheduledCommand = null; - this.syncTimeout = null; - - this.timeOffsetWithServer = 0; // server time minus local time - this.roundTripDuration = 0; - this.notifySyncPlayReady = false; - - Events.on(playbackManager, 'playbackstart', (player, state) => { - this.onPlaybackStart(player, state); - }); - - Events.on(playbackManager, 'playbackstop', (stopInfo) => { - this.onPlaybackStop(stopInfo); - }); - - Events.on(playbackManager, 'playerchange', () => { - this.onPlayerChange(); - }); - - this.bindToPlayer(playbackManager.getCurrentPlayer()); - - Events.on(this, 'timeupdate', (event) => { - this.syncPlaybackTime(); - }); - - Events.on(timeSyncManager, 'update', (event, error, timeOffset, ping) => { - if (error) { - console.debug('SyncPlay, time update issue', error); - return; - } - - this.timeOffsetWithServer = timeOffset; - this.roundTripDuration = ping * 2; - - if (this.notifySyncPlayReady) { - this.syncPlayReady = true; - Events.trigger(this, 'ready'); - this.notifySyncPlayReady = false; - } - - // Report ping - if (this.syncEnabled) { - const apiClient = ServerConnections.currentApiClient(); - const sessionId = getActivePlayerId(); - - if (!sessionId) { - this.signalError(); - toast({ - text: globalize.translate('MessageSyncPlayErrorMissingSession') - }); - return; - } - - apiClient.sendSyncPlayPing({ - Ping: ping - }); - } - }); - } - - /** - * Called when playback starts. - */ - onPlaybackStart (player, state) { - Events.trigger(this, 'playbackstart', [player, state]); - } - - /** - * Called when playback stops. - */ - onPlaybackStop (stopInfo) { - Events.trigger(this, 'playbackstop', [stopInfo]); - if (this.isSyncPlayEnabled()) { - this.disableSyncPlay(false); - } - } - - /** - * Called when the player changes. - */ - onPlayerChange () { - this.bindToPlayer(playbackManager.getCurrentPlayer()); - Events.trigger(this, 'playerchange', [this.currentPlayer]); - } - - /** - * Called when playback unpauses. - */ - onPlayerUnpause () { - Events.trigger(this, 'unpause', [this.currentPlayer]); - } - - /** - * Called when playback pauses. - */ - onPlayerPause() { - Events.trigger(this, 'pause', [this.currentPlayer]); - } - - /** - * Called on playback progress. - * @param {Object} e The time update event. - */ - onTimeUpdate (e) { - // NOTICE: this event is unreliable, at least in Safari - // which just stops firing the event after a while. - Events.trigger(this, 'timeupdate', [e]); - } - - /** - * Called when playback is resumed. - */ - onPlaying () { - // TODO: implement group wait - this.lastPlaybackWaiting = null; - Events.trigger(this, 'playing'); - } - - /** - * Called when playback is buffering. - */ - onWaiting () { - // TODO: implement group wait - if (!this.lastPlaybackWaiting) { - this.lastPlaybackWaiting = new Date(); - } - - Events.trigger(this, 'waiting'); - } - - /** - * Gets playback buffering status. - * @returns {boolean} _true_ if player is buffering, _false_ otherwise. - */ - isBuffering () { - if (this.lastPlaybackWaiting === null) return false; - return (new Date() - this.lastPlaybackWaiting) > this.minBufferingThresholdMillis; - } - - /** - * Binds to the player's events. - * @param {Object} player The player. - */ - bindToPlayer (player) { - if (player !== this.currentPlayer) { - this.releaseCurrentPlayer(); - this.currentPlayer = player; - if (!player) return; - } - - // FIXME: the following are needed because the 'events' module - // is changing the scope when executing the callbacks. - // For instance, calling 'onPlayerUnpause' from the wrong scope breaks things because 'this' - // points to 'player' (the event emitter) instead of pointing to the SyncPlayManager singleton. - const self = this; - this._onPlayerUnpause = () => { - self.onPlayerUnpause(); - }; - - this._onPlayerPause = () => { - self.onPlayerPause(); - }; - - this._onTimeUpdate = (e) => { - self.onTimeUpdate(e); - }; - - this._onPlaying = () => { - self.onPlaying(); - }; - - this._onWaiting = () => { - self.onWaiting(); - }; - - Events.on(player, 'unpause', this._onPlayerUnpause); - Events.on(player, 'pause', this._onPlayerPause); - Events.on(player, 'timeupdate', this._onTimeUpdate); - Events.on(player, 'playing', this._onPlaying); - Events.on(player, 'waiting', this._onWaiting); - - // Save player current PlaybackRate value - if (player.supports && player.supports('PlaybackRate')) { - this.localPlayerPlaybackRate = player.getPlaybackRate(); - } - } - - /** - * Removes the bindings to the current player's events. - */ - releaseCurrentPlayer () { - const player = this.currentPlayer; - if (player) { - Events.off(player, 'unpause', this._onPlayerUnpause); - Events.off(player, 'pause', this._onPlayerPause); - Events.off(player, 'timeupdate', this._onTimeUpdate); - Events.off(player, 'playing', this._onPlaying); - Events.off(player, 'waiting', this._onWaiting); - // Restore player original PlaybackRate value - if (this.playbackRateSupported) { - player.setPlaybackRate(this.localPlayerPlaybackRate); - this.localPlayerPlaybackRate = 1.0; - } - - this.currentPlayer = null; - this.playbackRateSupported = false; - } - } - - /** - * Handles a group update from the server. - * @param {Object} cmd The group update. - * @param {Object} apiClient The ApiClient. - */ - processGroupUpdate (cmd, apiClient) { - switch (cmd.Type) { - case 'PrepareSession': - this.prepareSession(apiClient, cmd.GroupId, cmd.Data); - break; - case 'UserJoined': - toast({ - text: globalize.translate('MessageSyncPlayUserJoined', cmd.Data) - }); - break; - case 'UserLeft': - toast({ - text: globalize.translate('MessageSyncPlayUserLeft', cmd.Data) - }); - break; - case 'GroupJoined': - this.enableSyncPlay(apiClient, new Date(cmd.Data), true); - break; - case 'NotInGroup': - case 'GroupLeft': - this.disableSyncPlay(true); - break; - case 'GroupWait': - toast({ - text: globalize.translate('MessageSyncPlayGroupWait', cmd.Data) - }); - break; - case 'GroupDoesNotExist': - toast({ - text: globalize.translate('MessageSyncPlayGroupDoesNotExist') - }); - break; - case 'CreateGroupDenied': - toast({ - text: globalize.translate('MessageSyncPlayCreateGroupDenied') - }); - break; - case 'JoinGroupDenied': - toast({ - text: globalize.translate('MessageSyncPlayJoinGroupDenied') - }); - break; - case 'LibraryAccessDenied': - toast({ - text: globalize.translate('MessageSyncPlayLibraryAccessDenied') - }); - break; - default: - console.error('processSyncPlayGroupUpdate: command is not recognised: ' + cmd.Type); - break; - } - } - - /** - * Handles a playback command from the server. - * @param {Object} cmd The playback command. - * @param {Object} apiClient The ApiClient. - */ - processCommand (cmd, apiClient) { - if (cmd === null) return; - - if (!this.isSyncPlayEnabled()) { - console.debug('SyncPlay processCommand: SyncPlay not enabled, ignoring command', cmd); - return; - } - - if (!this.syncPlayReady) { - console.debug('SyncPlay processCommand: SyncPlay not ready, queued command', cmd); - this.queuedCommand = cmd; - return; - } - - cmd.When = new Date(cmd.When); - cmd.EmittedAt = new Date(cmd.EmitttedAt); - - if (cmd.EmitttedAt < this.syncPlayEnabledAt) { - console.debug('SyncPlay processCommand: ignoring old command', cmd); - return; - } - - // Check if new command differs from last one - if (this.lastCommand && - this.lastCommand.When === cmd.When && - this.lastCommand.PositionTicks === cmd.PositionTicks && - this.Command === cmd.Command - ) { - console.debug('SyncPlay processCommand: ignoring duplicate command', cmd); - return; - } - - this.lastCommand = cmd; - console.log('SyncPlay will', cmd.Command, 'at', cmd.When, 'PositionTicks', cmd.PositionTicks); - - switch (cmd.Command) { - case 'Play': - this.schedulePlay(cmd.When, cmd.PositionTicks); - break; - case 'Pause': - this.schedulePause(cmd.When, cmd.PositionTicks); - break; - case 'Seek': - this.scheduleSeek(cmd.When, cmd.PositionTicks); - break; - default: - console.error('processCommand: command is not recognised: ' + cmd.Type); - break; - } - } - - /** - * Prepares this client to join a group by loading the required content. - * @param {Object} apiClient The ApiClient. - * @param {string} groupId The group to join. - * @param {Object} sessionData Info about the content to load. - */ - prepareSession (apiClient, groupId, sessionData) { - const serverId = apiClient.serverInfo().Id; - playbackManager.play({ - ids: sessionData.ItemIds, - startPositionTicks: sessionData.StartPositionTicks, - mediaSourceId: sessionData.MediaSourceId, - audioStreamIndex: sessionData.AudioStreamIndex, - subtitleStreamIndex: sessionData.SubtitleStreamIndex, - startIndex: sessionData.StartIndex, - serverId: serverId - }).then(() => { - waitForEventOnce(this, 'playbackstart', WaitForEventDefaultTimeout).then(() => { - const sessionId = getActivePlayerId(); - if (!sessionId) { - console.error('Missing sessionId!'); - toast({ - text: globalize.translate('MessageSyncPlayErrorMissingSession') - }); - return; - } - - // Get playing item id - let playingItemId; - try { - const playState = playbackManager.getPlayerState(); - playingItemId = playState.NowPlayingItem.Id; - } catch (error) { - playingItemId = ''; - } - // Make sure the server has received the player state - waitForEventOnce(playbackManager, 'reportplayback', WaitForEventDefaultTimeout).then((success) => { - this.localPause(); - if (!success) { - console.warning('Error reporting playback state to server. Joining group will fail.'); - } - apiClient.joinSyncPlayGroup({ - GroupId: groupId, - PlayingItemId: playingItemId - }); - }).catch(() => { - console.error('Timed out while waiting for `reportplayback` event!'); - toast({ - text: globalize.translate('MessageSyncPlayErrorMedia') - }); - return; - }); - }).catch(() => { - console.error('Timed out while waiting for `playbackstart` event!'); - if (!this.isSyncPlayEnabled()) { - toast({ - text: globalize.translate('MessageSyncPlayErrorMedia') - }); - } - return; - }); - }).catch((error) => { - console.error(error); - toast({ - text: globalize.translate('MessageSyncPlayErrorMedia') - }); - }); - } - - /** - * Enables SyncPlay. - * @param {Object} apiClient The ApiClient. - * @param {Date} enabledAt When SyncPlay has been enabled. Server side date. - * @param {boolean} showMessage Display message. - */ - enableSyncPlay (apiClient, enabledAt, showMessage = false) { - this.syncPlayEnabledAt = enabledAt; - this.injectPlaybackManager(); - Events.trigger(this, 'enabled', [true]); - - waitForEventOnce(this, 'ready').then(() => { - this.processCommand(this.queuedCommand, apiClient); - this.queuedCommand = null; - }); - - this.syncPlayReady = false; - this.notifySyncPlayReady = true; - - timeSyncManager.forceUpdate(); - - if (showMessage) { - toast({ - text: globalize.translate('MessageSyncPlayEnabled') - }); - } - } - - /** - * Disables SyncPlay. - * @param {boolean} showMessage Display message. - */ - disableSyncPlay (showMessage = false) { - this.syncPlayEnabledAt = null; - this.syncPlayReady = false; - this.lastCommand = null; - this.queuedCommand = null; - this.syncEnabled = false; - Events.trigger(this, 'enabled', [false]); - this.restorePlaybackManager(); - - if (showMessage) { - toast({ - text: globalize.translate('MessageSyncPlayDisabled') - }); - } - } - - /** - * Gets SyncPlay status. - * @returns {boolean} _true_ if user joined a group, _false_ otherwise. - */ - isSyncPlayEnabled () { - return this.syncPlayEnabledAt !== null; - } - - /** - * Schedules a resume playback on the player at the specified clock time. - * @param {Date} playAtTime The server's UTC time at which to resume playback. - * @param {number} positionTicks The PositionTicks from where to resume. - */ - schedulePlay (playAtTime, positionTicks) { - this.clearScheduledCommand(); - const currentTime = new Date(); - const playAtTimeLocal = timeSyncManager.serverDateToLocal(playAtTime); - - if (playAtTimeLocal > currentTime) { - const playTimeout = playAtTimeLocal - currentTime; - this.localSeek(positionTicks); - - this.scheduledCommand = setTimeout(() => { - this.localUnpause(); - - this.syncTimeout = setTimeout(() => { - this.syncEnabled = true; - }, SyncMethodThreshold / 2); - }, playTimeout); - - console.debug('Scheduled play in', playTimeout / 1000.0, 'seconds.'); - } else { - // Group playback already started - const serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000; - waitForEventOnce(this, 'unpause').then(() => { - this.localSeek(serverPositionTicks); - }); - this.localUnpause(); - - this.syncTimeout = setTimeout(() => { - this.syncEnabled = true; - }, SyncMethodThreshold / 2); - } - } - - /** - * Schedules a pause playback on the player at the specified clock time. - * @param {Date} pauseAtTime The server's UTC time at which to pause playback. - * @param {number} positionTicks The PositionTicks where player will be paused. - */ - schedulePause (pauseAtTime, positionTicks) { - this.clearScheduledCommand(); - const currentTime = new Date(); - const pauseAtTimeLocal = timeSyncManager.serverDateToLocal(pauseAtTime); - - const callback = () => { - waitForEventOnce(this, 'pause', WaitForPlayerEventTimeout).then(() => { - this.localSeek(positionTicks); - }).catch(() => { - // Player was already paused, seeking - this.localSeek(positionTicks); - }); - this.localPause(); - }; - - if (pauseAtTimeLocal > currentTime) { - const pauseTimeout = pauseAtTimeLocal - currentTime; - this.scheduledCommand = setTimeout(callback, pauseTimeout); - - console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.'); - } else { - callback(); - } - } - - /** - * Schedules a seek playback on the player at the specified clock time. - * @param {Date} pauseAtTime The server's UTC time at which to seek playback. - * @param {number} positionTicks The PositionTicks where player will be seeked. - */ - scheduleSeek (seekAtTime, positionTicks) { - this.schedulePause(seekAtTime, positionTicks); - } - - /** - * Clears the current scheduled command. - */ - clearScheduledCommand () { - clearTimeout(this.scheduledCommand); - clearTimeout(this.syncTimeout); - - this.syncEnabled = false; - if (this.currentPlayer) { - this.currentPlayer.setPlaybackRate(1); - } - - this.clearSyncIcon(); - } - - /** - * Overrides some PlaybackManager's methods to intercept playback commands. - */ - injectPlaybackManager () { - if (!this.isSyncPlayEnabled()) return; - if (playbackManager.syncPlayEnabled) return; - - // TODO: make this less hacky - playbackManager._localUnpause = playbackManager.unpause; - playbackManager._localPause = playbackManager.pause; - playbackManager._localSeek = playbackManager.seek; - - playbackManager.unpause = this.playRequest; - playbackManager.pause = this.pauseRequest; - playbackManager.seek = this.seekRequest; - playbackManager.syncPlayEnabled = true; - } - - /** - * Restores original PlaybackManager's methods. - */ - restorePlaybackManager () { - if (this.isSyncPlayEnabled()) return; - if (!playbackManager.syncPlayEnabled) return; - - playbackManager.unpause = playbackManager._localUnpause; - playbackManager.pause = playbackManager._localPause; - playbackManager.seek = playbackManager._localSeek; - playbackManager.syncPlayEnabled = false; - } - - /** - * Overrides PlaybackManager's unpause method. - */ - playRequest (player) { - const apiClient = ServerConnections.currentApiClient(); - apiClient.requestSyncPlayStart(); - } - - /** - * Overrides PlaybackManager's pause method. - */ - pauseRequest (player) { - const apiClient = ServerConnections.currentApiClient(); - apiClient.requestSyncPlayPause(); - // Pause locally as well, to give the user some little control - playbackManager._localUnpause(player); - } - - /** - * Overrides PlaybackManager's seek method. - */ - seekRequest (PositionTicks, player) { - const apiClient = ServerConnections.currentApiClient(); - apiClient.requestSyncPlaySeek({ - PositionTicks: PositionTicks - }); - } - - /** - * Calls original PlaybackManager's unpause method. - */ - localUnpause(player) { - if (playbackManager.syncPlayEnabled) { - playbackManager._localUnpause(player); - } else { - playbackManager.unpause(player); - } - } - - /** - * Calls original PlaybackManager's pause method. - */ - localPause(player) { - if (playbackManager.syncPlayEnabled) { - playbackManager._localPause(player); - } else { - playbackManager.pause(player); - } - } - - /** - * Calls original PlaybackManager's seek method. - */ - localSeek(PositionTicks, player) { - if (playbackManager.syncPlayEnabled) { - playbackManager._localSeek(PositionTicks, player); - } else { - playbackManager.seek(PositionTicks, player); - } - } - - /** - * Attempts to sync playback time with estimated server time. - * - * When sync is enabled, the following will be checked: - * - check if local playback time is close enough to the server playback time - * If it is not, then a playback time sync will be attempted. - * Two methods of syncing are available: - * - SpeedToSync: speeds up the media for some time to catch up (default is one second) - * - SkipToSync: seeks the media to the estimated correct time - * SpeedToSync aims to reduce the delay as much as possible, whereas SkipToSync is less pretentious. - */ - syncPlaybackTime () { - // Attempt to sync only when media is playing. - if (!this.lastCommand || this.lastCommand.Command !== 'Play' || this.isBuffering()) return; - - const currentTime = new Date(); - - // Avoid overloading the browser - const elapsed = currentTime - this.lastSyncTime; - if (elapsed < SyncMethodThreshold / 2) return; - this.lastSyncTime = currentTime; - - const playAtTime = this.lastCommand.When; - - const currentPositionTicks = playbackManager.currentTime() * 10000; - // Estimate PositionTicks on server - const serverPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) + this.timeOffsetWithServer) * 10000; - // Measure delay that needs to be recovered - // diff might be caused by the player internally starting the playback - const diffMillis = (serverPositionTicks - currentPositionTicks) / 10000.0; - - this.playbackDiffMillis = diffMillis; - - if (this.syncEnabled) { - const absDiffMillis = Math.abs(diffMillis); - // TODO: SpeedToSync sounds bad on songs - // TODO: SpeedToSync is failing on Safari (Mojave); even if playbackRate is supported, some delay seems to exist - if (this.playbackRateSupported && absDiffMillis > MaxAcceptedDelaySpeedToSync && absDiffMillis < SyncMethodThreshold) { - // Disable SpeedToSync if it keeps failing - if (this.syncAttempts > MaxAttemptsSpeedToSync) { - this.playbackRateSupported = false; - } - // SpeedToSync method - const speed = 1 + diffMillis / SpeedToSyncTime; - - this.currentPlayer.setPlaybackRate(speed); - this.syncEnabled = false; - this.syncAttempts++; - this.showSyncIcon('SpeedToSync (x' + speed + ')'); - - this.syncTimeout = setTimeout(() => { - this.currentPlayer.setPlaybackRate(1); - this.syncEnabled = true; - this.clearSyncIcon(); - }, SpeedToSyncTime); - } else if (absDiffMillis > MaxAcceptedDelaySkipToSync) { - // Disable SkipToSync if it keeps failing - if (this.syncAttempts > MaxAttemptsSync) { - this.syncEnabled = false; - this.showSyncIcon('Sync disabled (too many attempts)'); - } - // SkipToSync method - this.localSeek(serverPositionTicks); - this.syncEnabled = false; - this.syncAttempts++; - this.showSyncIcon('SkipToSync (' + this.syncAttempts + ')'); - - this.syncTimeout = setTimeout(() => { - this.syncEnabled = true; - this.clearSyncIcon(); - }, SyncMethodThreshold / 2); - } else { - // Playback is synced - if (this.syncAttempts > 0) { - console.debug('Playback has been synced after', this.syncAttempts, 'attempts.'); - } - this.syncAttempts = 0; - } - } - } - - /** - * Gets SyncPlay stats. - * @returns {Object} The SyncPlay stats. - */ - getStats () { - return { - TimeOffset: this.timeOffsetWithServer, - PlaybackDiff: this.playbackDiffMillis, - SyncMethod: this.syncMethod - }; - } - - /** - * Emits an event to update the SyncPlay status icon. - */ - showSyncIcon (syncMethod) { - this.syncMethod = syncMethod; - Events.trigger(this, 'syncing', [true, this.syncMethod]); - } - - /** - * Emits an event to clear the SyncPlay status icon. - */ - clearSyncIcon () { - this.syncMethod = 'None'; - Events.trigger(this, 'syncing', [false, this.syncMethod]); - } - - /** - * Signals an error state, which disables and resets SyncPlay for a new session. - */ - signalError () { - this.disableSyncPlay(); - } -} - -/** SyncPlayManager singleton. */ -export default new SyncPlayManager(); diff --git a/src/components/syncPlay/ui/groupSelectionMenu.js b/src/components/syncPlay/ui/groupSelectionMenu.js new file mode 100644 index 00000000000..96a7310381f --- /dev/null +++ b/src/components/syncPlay/ui/groupSelectionMenu.js @@ -0,0 +1,189 @@ +import { Events } from 'jellyfin-apiclient'; +import SyncPlay from '../core'; +import loading from '../../loading/loading'; +import toast from '../../toast/toast'; +import actionsheet from '../../actionSheet/actionSheet'; +import globalize from '../../../scripts/globalize'; +import playbackPermissionManager from './playbackPermissionManager'; +import ServerConnections from '../../ServerConnections'; + +/** + * Class that manages the SyncPlay group selection menu. + */ +class GroupSelectionMenu { + constructor() { + // Register to SyncPlay events. + this.syncPlayEnabled = false; + Events.on(SyncPlay.Manager, 'enabled', (e, enabled) => { + this.syncPlayEnabled = enabled; + }); + } + + /** + * Used when user needs to join a group. + * @param {HTMLElement} button - Element where to place the menu. + * @param {Object} user - Current user. + * @param {Object} apiClient - ApiClient. + */ + showNewJoinGroupSelection(button, user, apiClient) { + const policy = user.localUser ? user.localUser.Policy : {}; + + apiClient.getSyncPlayGroups().then(function (response) { + response.json().then(function (groups) { + const menuItems = groups.map(function (group) { + return { + name: group.GroupName, + icon: 'person', + id: group.GroupId, + selected: false, + secondaryText: group.Participants.join(', ') + }; + }); + + if (policy.SyncPlayAccess === 'CreateAndJoinGroups') { + menuItems.push({ + name: globalize.translate('LabelSyncPlayNewGroup'), + icon: 'add', + id: 'new-group', + selected: true, + secondaryText: globalize.translate('LabelSyncPlayNewGroupDescription') + }); + } + + if (menuItems.length === 0 && policy.SyncPlayAccess === 'JoinGroups') { + toast({ + text: globalize.translate('MessageSyncPlayCreateGroupDenied') + }); + loading.hide(); + return; + } + + const menuOptions = { + title: globalize.translate('HeaderSyncPlaySelectGroup'), + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true + }; + + actionsheet.show(menuOptions).then(function (id) { + if (id == 'new-group') { + apiClient.createSyncPlayGroup({ + GroupName: globalize.translate('SyncPlayGroupDefaultTitle', user.localUser.Name) + }); + } else if (id) { + apiClient.joinSyncPlayGroup({ + GroupId: id + }); + } + }).catch((error) => { + console.error('SyncPlay: unexpected error listing groups:', error); + }); + + loading.hide(); + }); + }).catch(function (error) { + console.error(error); + loading.hide(); + toast({ + text: globalize.translate('MessageSyncPlayErrorAccessingGroups') + }); + }); + } + + /** + * Used when user has joined a group. + * @param {HTMLElement} button - Element where to place the menu. + * @param {Object} user - Current user. + * @param {Object} apiClient - ApiClient. + */ + showLeaveGroupSelection(button, user, apiClient) { + const groupInfo = SyncPlay.Manager.getGroupInfo(); + const menuItems = []; + + if (!SyncPlay.Manager.isPlaylistEmpty() && !SyncPlay.Manager.isPlaybackActive()) { + menuItems.push({ + name: globalize.translate('LabelSyncPlayResumePlayback'), + icon: 'play_circle_filled', + id: 'resume-playback', + selected: false, + secondaryText: globalize.translate('LabelSyncPlayResumePlaybackDescription') + }); + } else if (SyncPlay.Manager.isPlaybackActive()) { + menuItems.push({ + name: globalize.translate('LabelSyncPlayHaltPlayback'), + icon: 'pause_circle_filled', + id: 'halt-playback', + selected: false, + secondaryText: globalize.translate('LabelSyncPlayHaltPlaybackDescription') + }); + } + + menuItems.push({ + name: globalize.translate('LabelSyncPlayLeaveGroup'), + icon: 'meeting_room', + id: 'leave-group', + selected: true, + secondaryText: globalize.translate('LabelSyncPlayLeaveGroupDescription') + }); + + const menuOptions = { + title: groupInfo.GroupName, + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true + }; + + actionsheet.show(menuOptions).then(function (id) { + if (id == 'resume-playback') { + SyncPlay.Manager.resumeGroupPlayback(apiClient); + } else if (id == 'halt-playback') { + SyncPlay.Manager.haltGroupPlayback(apiClient); + } else if (id == 'leave-group') { + apiClient.leaveSyncPlayGroup(); + } + }).catch((error) => { + console.error('SyncPlay: unexpected error showing group menu:', error); + }); + + loading.hide(); + } + + /** + * Shows a menu to handle SyncPlay groups. + * @param {HTMLElement} button - Element where to place the menu. + */ + show(button) { + loading.show(); + + // TODO: should feature be disabled if playback permission is missing? + playbackPermissionManager.check().then(() => { + console.debug('Playback is allowed.'); + }).catch((error) => { + console.error('Playback not allowed!', error); + toast({ + text: globalize.translate('MessageSyncPlayPlaybackPermissionRequired') + }); + }); + + const apiClient = ServerConnections.currentApiClient(); + ServerConnections.user(apiClient).then((user) => { + if (this.syncPlayEnabled) { + this.showLeaveGroupSelection(button, user, apiClient); + } else { + this.showNewJoinGroupSelection(button, user, apiClient); + } + }).catch((error) => { + console.error(error); + loading.hide(); + toast({ + text: globalize.translate('MessageSyncPlayNoGroupsAvailable') + }); + }); + } +} + +/** GroupSelectionMenu singleton. */ +const groupSelectionMenu = new GroupSelectionMenu(); +export default groupSelectionMenu; diff --git a/src/components/syncPlay/playbackPermissionManager.js b/src/components/syncPlay/ui/playbackPermissionManager.js similarity index 100% rename from src/components/syncPlay/playbackPermissionManager.js rename to src/components/syncPlay/ui/playbackPermissionManager.js diff --git a/src/components/syncPlay/ui/players/HtmlAudioPlayer.js b/src/components/syncPlay/ui/players/HtmlAudioPlayer.js new file mode 100644 index 00000000000..89929eb6883 --- /dev/null +++ b/src/components/syncPlay/ui/players/HtmlAudioPlayer.js @@ -0,0 +1,19 @@ +/** + * Module that manages the HtmlAudioPlayer for SyncPlay. + * @module components/syncPlay/ui/players/HtmlAudioPlayer + */ + +import HtmlVideoPlayer from './HtmlVideoPlayer'; + +/** + * Class that manages the HtmlAudioPlayer for SyncPlay. + */ +class HtmlAudioPlayer extends HtmlVideoPlayer { + static type = 'htmlaudioplayer'; + + constructor(player, syncPlayManager) { + super(player, syncPlayManager); + } +} + +export default HtmlAudioPlayer; diff --git a/src/components/syncPlay/ui/players/HtmlVideoPlayer.js b/src/components/syncPlay/ui/players/HtmlVideoPlayer.js new file mode 100644 index 00000000000..cc045d49547 --- /dev/null +++ b/src/components/syncPlay/ui/players/HtmlVideoPlayer.js @@ -0,0 +1,155 @@ +/** + * Module that manages the HtmlVideoPlayer for SyncPlay. + * @module components/syncPlay/ui/players/HtmlVideoPlayer + */ + +import { Events } from 'jellyfin-apiclient'; +import NoActivePlayer from './NoActivePlayer'; + +/** + * Class that manages the HtmlVideoPlayer for SyncPlay. + */ +class HtmlVideoPlayer extends NoActivePlayer { + static type = 'htmlvideoplayer'; + + constructor(player, syncPlayManager) { + super(player, syncPlayManager); + this.isPlayerActive = false; + this.savedPlaybackRate = 1.0; + this.minBufferingThresholdMillis = 3000; + } + + /** + * Binds to the player's events. Overrides parent method. + * @param {Object} player The player. + */ + localBindToPlayer() { + super.localBindToPlayer(); + + const self = this; + + this._onPlaybackStart = (player, state) => { + self.isPlayerActive = true; + self.onPlaybackStart(player, state); + }; + + this._onPlaybackStop = (stopInfo) => { + self.isPlayerActive = false; + self.onPlaybackStop(stopInfo); + }; + + this._onUnpause = () => { + self.onUnpause(); + }; + + this._onPause = () => { + self.onPause(); + }; + + this._onTimeUpdate = (e) => { + const currentTime = new Date(); + const currentPosition = self.player.currentTime(); + self.onTimeUpdate(e, { + currentTime: currentTime, + currentPosition: currentPosition + }); + }; + + this._onPlaying = () => { + clearTimeout(self.notifyBuffering); + self.onReady(); + }; + + this._onWaiting = () => { + clearTimeout(self.notifyBuffering); + self.notifyBuffering = setTimeout(() => { + self.onBuffering(); + }, self.minBufferingThresholdMillis); + }; + + Events.on(this.player, 'playbackstart', this._onPlaybackStart); + Events.on(this.player, 'playbackstop', this._onPlaybackStop); + Events.on(this.player, 'unpause', this._onUnpause); + Events.on(this.player, 'pause', this._onPause); + Events.on(this.player, 'timeupdate', this._onTimeUpdate); + Events.on(this.player, 'playing', this._onPlaying); + Events.on(this.player, 'waiting', this._onWaiting); + + this.savedPlaybackRate = this.player.getPlaybackRate(); + } + + /** + * Removes the bindings from the player's events. Overrides parent method. + */ + localUnbindFromPlayer() { + super.localUnbindFromPlayer(); + + Events.off(this.player, 'playbackstart', this._onPlaybackStart); + Events.off(this.player, 'playbackstop', this._onPlaybackStop); + Events.off(this.player, 'unpause', this._onPlayerUnpause); + Events.off(this.player, 'pause', this._onPlayerPause); + Events.off(this.player, 'timeupdate', this._onTimeUpdate); + Events.off(this.player, 'playing', this._onPlaying); + Events.off(this.player, 'waiting', this._onWaiting); + + this.player.setPlaybackRate(this.savedPlaybackRate); + } + + /** + * Called when changes are made to the play queue. + */ + onQueueUpdate() { + // TODO: find a more generic event? Tests show that this is working for now. + Events.trigger(this.player, 'playlistitemadd'); + } + + /** + * Gets player status. + * @returns {boolean} Whether the player has some media loaded. + */ + isPlaybackActive() { + return this.isPlayerActive; + } + + /** + * Gets playback status. + * @returns {boolean} Whether the playback is unpaused. + */ + isPlaying() { + return !this.player.paused(); + } + + /** + * Gets playback position. + * @returns {number} The player position, in milliseconds. + */ + currentTime() { + return this.player.currentTime(); + } + + /** + * Checks if player has playback rate support. + * @returns {boolean} _true _ if playback rate is supported, false otherwise. + */ + hasPlaybackRate() { + return true; + } + + /** + * Sets the playback rate, if supported. + * @param {number} value The playback rate. + */ + setPlaybackRate(value) { + this.player.setPlaybackRate(value); + } + + /** + * Gets the playback rate. + * @returns {number} The playback rate. + */ + getPlaybackRate() { + return this.player.getPlaybackRate(); + } +} + +export default HtmlVideoPlayer; diff --git a/src/components/syncPlay/ui/players/NoActivePlayer.js b/src/components/syncPlay/ui/players/NoActivePlayer.js new file mode 100644 index 00000000000..48dfe953baf --- /dev/null +++ b/src/components/syncPlay/ui/players/NoActivePlayer.js @@ -0,0 +1,444 @@ +/** + * Module that manages the PlaybackManager when there's no active player. + * @module components/syncPlay/ui/players/NoActivePlayer + */ + +import { playbackManager } from '../../../playback/playbackmanager'; +import SyncPlay from '../../core'; +import QueueManager from './QueueManager'; + +let syncPlayManager; + +/** + * Class that manages the PlaybackManager when there's no active player. + */ +class NoActivePlayer extends SyncPlay.Players.GenericPlayer { + static type = 'default'; + + constructor(player, _syncPlayManager) { + super(player, _syncPlayManager); + syncPlayManager = _syncPlayManager; + } + + /** + * Binds to the player's events. + */ + localBindToPlayer() { + if (playbackManager.syncPlayEnabled) return; + + // Save local callbacks. + playbackManager._localPlayPause = playbackManager.playPause; + playbackManager._localUnpause = playbackManager.unpause; + playbackManager._localPause = playbackManager.pause; + playbackManager._localSeek = playbackManager.seek; + playbackManager._localSendCommand = playbackManager.sendCommand; + + // Override local callbacks. + playbackManager.playPause = this.playPauseRequest; + playbackManager.unpause = this.unpauseRequest; + playbackManager.pause = this.pauseRequest; + playbackManager.seek = this.seekRequest; + playbackManager.sendCommand = this.sendCommandRequest; + + // Save local callbacks. + playbackManager._localPlayQueueManager = playbackManager._playQueueManager; + + playbackManager._localPlay = playbackManager.play; + playbackManager._localSetCurrentPlaylistItem = playbackManager.setCurrentPlaylistItem; + playbackManager._localRemoveFromPlaylist = playbackManager.removeFromPlaylist; + playbackManager._localMovePlaylistItem = playbackManager.movePlaylistItem; + playbackManager._localQueue = playbackManager.queue; + playbackManager._localQueueNext = playbackManager.queueNext; + + playbackManager._localNextTrack = playbackManager.nextTrack; + playbackManager._localPreviousTrack = playbackManager.previousTrack; + + playbackManager._localSetRepeatMode = playbackManager.setRepeatMode; + playbackManager._localSetQueueShuffleMode = playbackManager.setQueueShuffleMode; + playbackManager._localToggleQueueShuffleMode = playbackManager.toggleQueueShuffleMode; + + // Override local callbacks. + playbackManager._playQueueManager = new QueueManager(this.manager); + + playbackManager.play = this.playRequest; + playbackManager.setCurrentPlaylistItem = this.setCurrentPlaylistItemRequest; + playbackManager.removeFromPlaylist = this.removeFromPlaylistRequest; + playbackManager.movePlaylistItem = this.movePlaylistItemRequest; + playbackManager.queue = this.queueRequest; + playbackManager.queueNext = this.queueNextRequest; + + playbackManager.nextTrack = this.nextTrackRequest; + playbackManager.previousTrack = this.previousTrackRequest; + + playbackManager.setRepeatMode = this.setRepeatModeRequest; + playbackManager.setQueueShuffleMode = this.setQueueShuffleModeRequest; + playbackManager.toggleQueueShuffleMode = this.toggleQueueShuffleModeRequest; + + playbackManager.syncPlayEnabled = true; + } + + /** + * Removes the bindings from the player's events. + */ + localUnbindFromPlayer() { + if (!playbackManager.syncPlayEnabled) return; + + playbackManager.playPause = playbackManager._localPlayPause; + playbackManager.unpause = playbackManager._localUnpause; + playbackManager.pause = playbackManager._localPause; + playbackManager.seek = playbackManager._localSeek; + playbackManager.sendCommand = playbackManager._localSendCommand; + + playbackManager._playQueueManager = playbackManager._localPlayQueueManager; // TODO: should move elsewhere? + + playbackManager.play = playbackManager._localPlay; + playbackManager.setCurrentPlaylistItem = playbackManager._localSetCurrentPlaylistItem; + playbackManager.removeFromPlaylist = playbackManager._localRemoveFromPlaylist; + playbackManager.movePlaylistItem = playbackManager._localMovePlaylistItem; + playbackManager.queue = playbackManager._localQueue; + playbackManager.queueNext = playbackManager._localQueueNext; + + playbackManager.nextTrack = playbackManager._localNextTrack; + playbackManager.previousTrack = playbackManager._localPreviousTrack; + + playbackManager.setRepeatMode = playbackManager._localSetRepeatMode; + playbackManager.setQueueShuffleMode = playbackManager._localSetQueueShuffleMode; + playbackManager.toggleQueueShuffleMode = playbackManager._localToggleQueueShuffleMode; + + playbackManager.syncPlayEnabled = false; + } + + /** + * Overrides PlaybackManager's playPause method. + */ + playPauseRequest() { + const controller = syncPlayManager.getController(); + controller.playPause(); + } + + /** + * Overrides PlaybackManager's unpause method. + */ + unpauseRequest() { + const controller = syncPlayManager.getController(); + controller.unpause(); + } + + /** + * Overrides PlaybackManager's pause method. + */ + pauseRequest() { + const controller = syncPlayManager.getController(); + controller.pause(); + } + + /** + * Overrides PlaybackManager's seek method. + */ + seekRequest(positionTicks, player) { + const controller = syncPlayManager.getController(); + controller.seek(positionTicks); + } + + /** + * Overrides PlaybackManager's sendCommand method. + */ + sendCommandRequest(command, player) { + console.debug('SyncPlay sendCommand:', command.Name, command); + const controller = syncPlayManager.getController(); + const playerWrapper = syncPlayManager.getPlayerWrapper(); + + const defaultAction = (_command, _player) => { + playerWrapper.localSendCommand(_command); + }; + + const ignoreCallback = (_command, _player) => { + // Do nothing. + }; + + const SetRepeatModeCallback = (_command, _player) => { + controller.setRepeatMode(_command.Arguments.RepeatMode); + }; + + const SetShuffleQueueCallback = (_command, _player) => { + controller.setShuffleMode(_command.Arguments.ShuffleMode); + }; + + // Commands to override. + const overrideCommands = { + PlaybackRate: ignoreCallback, + SetRepeatMode: SetRepeatModeCallback, + SetShuffleQueue: SetShuffleQueueCallback + }; + + // Handle command. + const commandHandler = overrideCommands[command.Name]; + if (typeof commandHandler === 'function') { + commandHandler(command, player); + } else { + defaultAction(command, player); + } + } + + /** + * Calls original PlaybackManager's unpause method. + */ + localUnpause() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localUnpause(this.player); + } else { + playbackManager.unpause(this.player); + } + } + + /** + * Calls original PlaybackManager's pause method. + */ + localPause() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localPause(this.player); + } else { + playbackManager.pause(this.player); + } + } + + /** + * Calls original PlaybackManager's seek method. + */ + localSeek(positionTicks) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSeek(positionTicks, this.player); + } else { + playbackManager.seek(positionTicks, this.player); + } + } + + /** + * Calls original PlaybackManager's stop method. + */ + localStop() { + playbackManager.stop(this.player); + } + + /** + * Calls original PlaybackManager's sendCommand method. + */ + localSendCommand(cmd) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSendCommand(cmd, this.player); + } else { + playbackManager.sendCommand(cmd, this.player); + } + } + + /** + * Overrides PlaybackManager's play method. + */ + playRequest(options) { + const controller = syncPlayManager.getController(); + controller.play(options); + } + + /** + * Overrides PlaybackManager's setCurrentPlaylistItem method. + */ + setCurrentPlaylistItemRequest(playlistItemId, player) { + const controller = syncPlayManager.getController(); + controller.setCurrentPlaylistItem(playlistItemId); + } + + /** + * Overrides PlaybackManager's removeFromPlaylist method. + */ + removeFromPlaylistRequest(playlistItemIds, player) { + const controller = syncPlayManager.getController(); + controller.removeFromPlaylist(playlistItemIds); + } + + /** + * Overrides PlaybackManager's movePlaylistItem method. + */ + movePlaylistItemRequest(playlistItemId, newIndex, player) { + const controller = syncPlayManager.getController(); + controller.movePlaylistItem(playlistItemId, newIndex); + } + + /** + * Overrides PlaybackManager's queue method. + */ + queueRequest(options, player) { + const controller = syncPlayManager.getController(); + controller.queue(options); + } + + /** + * Overrides PlaybackManager's queueNext method. + */ + queueNextRequest(options, player) { + const controller = syncPlayManager.getController(); + controller.queueNext(options); + } + + /** + * Overrides PlaybackManager's nextTrack method. + */ + nextTrackRequest(player) { + const controller = syncPlayManager.getController(); + controller.nextItem(); + } + + /** + * Overrides PlaybackManager's previousTrack method. + */ + previousTrackRequest(player) { + const controller = syncPlayManager.getController(); + controller.previousItem(); + } + + /** + * Overrides PlaybackManager's setRepeatMode method. + */ + setRepeatModeRequest(mode, player) { + const controller = syncPlayManager.getController(); + controller.setRepeatMode(mode); + } + + /** + * Overrides PlaybackManager's setQueueShuffleMode method. + */ + setQueueShuffleModeRequest(mode, player) { + const controller = syncPlayManager.getController(); + controller.setShuffleMode(mode); + } + + /** + * Overrides PlaybackManager's toggleQueueShuffleMode method. + */ + toggleQueueShuffleModeRequest(player) { + const controller = syncPlayManager.getController(); + controller.toggleShuffleMode(); + } + + /** + * Calls original PlaybackManager's play method. + */ + localPlay(options) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localPlay(options); + } else { + return playbackManager.play(options); + } + } + + /** + * Calls original PlaybackManager's setCurrentPlaylistItem method. + */ + localSetCurrentPlaylistItem(playlistItemId) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localSetCurrentPlaylistItem(playlistItemId, this.player); + } else { + return playbackManager.setCurrentPlaylistItem(playlistItemId, this.player); + } + } + + /** + * Calls original PlaybackManager's removeFromPlaylist method. + */ + localRemoveFromPlaylist(playlistItemIds) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localRemoveFromPlaylist(playlistItemIds, this.player); + } else { + return playbackManager.removeFromPlaylist(playlistItemIds, this.player); + } + } + + /** + * Calls original PlaybackManager's movePlaylistItem method. + */ + localMovePlaylistItem(playlistItemId, newIndex) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localMovePlaylistItem(playlistItemId, newIndex, this.player); + } else { + return playbackManager.movePlaylistItem(playlistItemId, newIndex, this.player); + } + } + + /** + * Calls original PlaybackManager's queue method. + */ + localQueue(options) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localQueue(options, this.player); + } else { + return playbackManager.queue(options, this.player); + } + } + + /** + * Calls original PlaybackManager's queueNext method. + */ + localQueueNext(options) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localQueueNext(options, this.player); + } else { + return playbackManager.queueNext(options, this.player); + } + } + + /** + * Calls original PlaybackManager's nextTrack method. + */ + localNextItem() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localNextTrack(this.player); + } else { + playbackManager.nextTrack(this.player); + } + } + + /** + * Calls original PlaybackManager's previousTrack method. + */ + localPreviousItem() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localPreviousTrack(this.player); + } else { + playbackManager.previousTrack(this.player); + } + } + + /** + * Calls original PlaybackManager's setRepeatMode method. + */ + localSetRepeatMode(value) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSetRepeatMode(value, this.player); + } else { + playbackManager.setRepeatMode(value, this.player); + } + } + + /** + * Calls original PlaybackManager's setQueueShuffleMode method. + */ + localSetQueueShuffleMode(value) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSetQueueShuffleMode(value, this.player); + } else { + playbackManager.setQueueShuffleMode(value, this.player); + } + } + + /** + * Calls original PlaybackManager's toggleQueueShuffleMode method. + */ + localToggleQueueShuffleMode() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localToggleQueueShuffleMode(this.player); + } else { + playbackManager.toggleQueueShuffleMode(this.player); + } + } +} + +export default NoActivePlayer; diff --git a/src/components/syncPlay/ui/players/QueueManager.js b/src/components/syncPlay/ui/players/QueueManager.js new file mode 100644 index 00000000000..bcc6c1c2b39 --- /dev/null +++ b/src/components/syncPlay/ui/players/QueueManager.js @@ -0,0 +1,202 @@ +/** + * Module that replaces the PlaybackManager's queue. + * @module components/syncPlay/ui/players/QueueManager + */ + +/** + * Class that replaces the PlaybackManager's queue. + */ +class QueueManager { + constructor(syncPlayManager) { + this.queueCore = syncPlayManager.getQueueCore(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getPlaylist() { + return this.queueCore.getPlaylist(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setPlaylist(items) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + queue(items) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + shufflePlaylist() { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + sortShuffledPlaylist() { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + clearPlaylist(clearCurrentItem = false) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + queueNext(items) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getCurrentPlaylistIndex() { + return this.queueCore.getCurrentPlaylistIndex(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getCurrentItem() { + const index = this.getCurrentPlaylistIndex(); + if (index >= 0) { + const playlist = this.getPlaylist(); + return playlist[index]; + } else { + return null; + } + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getCurrentPlaylistItemId() { + return this.queueCore.getCurrentPlaylistItemId(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setPlaylistState(playlistItemId, playlistIndex) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setPlaylistIndex(playlistIndex) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + removeFromPlaylist(playlistItemIds) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + movePlaylistItem(playlistItemId, newIndex) { + // Do nothing. + return { + result: 'noop' + }; + } + + /** + * Placeholder for original PlayQueueManager method. + */ + reset() { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setRepeatMode(value) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getRepeatMode() { + return this.queueCore.getRepeatMode(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setShuffleMode(value) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + toggleShuffleMode() { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getShuffleMode() { + return this.queueCore.getShuffleMode(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getNextItemInfo() { + const playlist = this.getPlaylist(); + let newIndex; + + switch (this.getRepeatMode()) { + case 'RepeatOne': + newIndex = this.getCurrentPlaylistIndex(); + break; + case 'RepeatAll': + newIndex = this.getCurrentPlaylistIndex() + 1; + if (newIndex >= playlist.length) { + newIndex = 0; + } + break; + default: + newIndex = this.getCurrentPlaylistIndex() + 1; + break; + } + + if (newIndex < 0 || newIndex >= playlist.length) { + return null; + } + + const item = playlist[newIndex]; + + if (!item) { + return null; + } + + return { + item: item, + index: newIndex + }; + } +} + +export default QueueManager; diff --git a/src/components/syncPlay/ui/syncPlayToasts.js b/src/components/syncPlay/ui/syncPlayToasts.js new file mode 100644 index 00000000000..8c8d4c08590 --- /dev/null +++ b/src/components/syncPlay/ui/syncPlayToasts.js @@ -0,0 +1,34 @@ +/** + * Module that notifies user about SyncPlay messages using toasts. + * @module components/syncPlay/syncPlayToasts + */ + +import { Events } from 'jellyfin-apiclient'; +import toast from '../../toast/toast'; +import globalize from '../../../scripts/globalize'; +import SyncPlay from '../core'; + +/** + * Class that notifies user about SyncPlay messages using toasts. + */ +class SyncPlayToasts { + constructor() { + // Do nothing. + } + + /** + * Listens for messages to show. + */ + init() { + Events.on(SyncPlay.Manager, 'show-message', (event, data) => { + const { message, args = [] } = data; + toast({ + text: globalize.translate(message, ...args) + }); + }); + } +} + +/** SyncPlayToasts singleton. */ +const syncPlayToasts = new SyncPlayToasts(); +export default syncPlayToasts; diff --git a/src/components/viewContainer.js b/src/components/viewContainer.js index 7bec82ee751..cfff424a132 100644 --- a/src/components/viewContainer.js +++ b/src/components/viewContainer.js @@ -1,3 +1,4 @@ +import { importModule } from '@uupaa/dynamic-import-polyfill'; import './viewManager/viewContainer.css'; import Dashboard from '../scripts/clientUtils'; @@ -17,7 +18,7 @@ import Dashboard from '../scripts/clientUtils'; controllerUrl = Dashboard.getPluginUrl(controllerUrl); const apiUrl = ApiClient.getUrl('/web/' + controllerUrl); - return import(/* webpackIgnore: true */ apiUrl).then((ControllerFactory) => { + return importModule(apiUrl).then((ControllerFactory) => { options.controllerFactory = ControllerFactory; }); } diff --git a/src/components/viewSettings/viewSettings.js b/src/components/viewSettings/viewSettings.js index 0f32f4f6ac8..46bc3864eef 100644 --- a/src/components/viewSettings/viewSettings.js +++ b/src/components/viewSettings/viewSettings.js @@ -10,6 +10,7 @@ import '../../elements/emby-select/emby-select'; import 'material-design-icons-iconfont'; import '../formdialog.css'; import '../../assets/css/flexstyles.scss'; +import template from './viewSettings.template.html'; function onSubmit(e) { e.preventDefault(); @@ -59,81 +60,79 @@ class ViewSettings { } show(options) { return new Promise(function (resolve, reject) { - import('./viewSettings.template.html').then(({default: template}) => { - const dialogOptions = { - removeOnClose: true, - scrollY: false - }; + const dialogOptions = { + removeOnClose: true, + scrollY: false + }; - if (layoutManager.tv) { - dialogOptions.size = 'fullscreen'; - } else { - dialogOptions.size = 'small'; - } + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } - const dlg = dialogHelper.createDialog(dialogOptions); + const dlg = dialogHelper.createDialog(dialogOptions); - dlg.classList.add('formDialog'); + dlg.classList.add('formDialog'); - let html = ''; + let html = ''; - html += '
'; - html += ''; - html += '

${Settings}

'; + html += '
'; + html += ''; + html += '

${Settings}

'; - html += '
'; + html += '
'; - html += template; + html += template; - dlg.innerHTML = globalize.translateHtml(html, 'core'); + dlg.innerHTML = globalize.translateHtml(html, 'core'); - const settingElements = dlg.querySelectorAll('.viewSetting'); - for (const settingElement of settingElements) { - if (options.visibleSettings.indexOf(settingElement.getAttribute('data-settingname')) === -1) { - settingElement.classList.add('hide'); - settingElement.classList.add('hiddenFromViewSettings'); - } else { - settingElement.classList.remove('hide'); - settingElement.classList.remove('hiddenFromViewSettings'); - } + const settingElements = dlg.querySelectorAll('.viewSetting'); + for (const settingElement of settingElements) { + if (options.visibleSettings.indexOf(settingElement.getAttribute('data-settingname')) === -1) { + settingElement.classList.add('hide'); + settingElement.classList.add('hiddenFromViewSettings'); + } else { + settingElement.classList.remove('hide'); + settingElement.classList.remove('hiddenFromViewSettings'); } + } - initEditor(dlg, options.settings); + initEditor(dlg, options.settings); - dlg.querySelector('.selectImageType').addEventListener('change', function () { - showIfAllowed(dlg, '.chkTitleContainer', this.value !== 'list'); - showIfAllowed(dlg, '.chkYearContainer', this.value !== 'list'); - }); + dlg.querySelector('.selectImageType').addEventListener('change', function () { + showIfAllowed(dlg, '.chkTitleContainer', this.value !== 'list'); + showIfAllowed(dlg, '.chkYearContainer', this.value !== 'list'); + }); - dlg.querySelector('.btnCancel').addEventListener('click', function () { - dialogHelper.close(dlg); - }); + dlg.querySelector('.btnCancel').addEventListener('click', function () { + dialogHelper.close(dlg); + }); - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, true); - } + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, true); + } - let submitted; + let submitted; - dlg.querySelector('.selectImageType').dispatchEvent(new CustomEvent('change', {})); + dlg.querySelector('.selectImageType').dispatchEvent(new CustomEvent('change', {})); - dlg.querySelector('form').addEventListener('change', function () { - submitted = true; - }, true); + dlg.querySelector('form').addEventListener('change', function () { + submitted = true; + }, true); - dialogHelper.open(dlg).then(function () { - if (layoutManager.tv) { - centerFocus(dlg.querySelector('.formDialogContent'), false, false); - } + dialogHelper.open(dlg).then(function () { + if (layoutManager.tv) { + centerFocus(dlg.querySelector('.formDialogContent'), false, false); + } - if (submitted) { - saveValues(dlg, options.settings, options.settingsKey); - resolve(); - return; - } + if (submitted) { + saveValues(dlg, options.settings, options.settingsKey); + resolve(); + return; + } - reject(); - }); + reject(); }); }); } diff --git a/src/config.json b/src/config.json index b896b5f068d..9dd6fa01d6c 100644 --- a/src/config.json +++ b/src/config.json @@ -1,4 +1,5 @@ { + "includeCorsCredentials": false, "multiserver": false, "themes": [ { diff --git a/src/controllers/dashboard/dashboard.html b/src/controllers/dashboard/dashboard.html index 83d30495e51..9b177e3f345 100644 --- a/src/controllers/dashboard/dashboard.html +++ b/src/controllers/dashboard/dashboard.html @@ -3,7 +3,7 @@
- +

${TabServer}

@@ -35,7 +35,7 @@

${HeaderRunningTasks}

- +

${HeaderActiveDevices}

@@ -46,7 +46,7 @@

${HeaderActiveDevices}

- +

${HeaderActivity}

@@ -63,7 +63,7 @@

${HeaderActiveRecordings}

- +

${Alerts}

@@ -72,7 +72,7 @@

${Alerts}

- +

${HeaderPaths}

diff --git a/src/controllers/dashboard/devices/devices.html b/src/controllers/dashboard/devices/devices.html index e2504cd3e7d..21333048995 100644 --- a/src/controllers/dashboard/devices/devices.html +++ b/src/controllers/dashboard/devices/devices.html @@ -5,7 +5,7 @@

${HeaderDevices}

${Help} - +
diff --git a/src/controllers/dashboard/devices/devices.js b/src/controllers/dashboard/devices/devices.js index e1eb99e677b..63d2a7645e9 100644 --- a/src/controllers/dashboard/devices/devices.js +++ b/src/controllers/dashboard/devices/devices.js @@ -96,7 +96,7 @@ import confirm from '../../../components/confirm/confirm'; deviceHtml += '
'; deviceHtml += '
'; deviceHtml += '
'; - deviceHtml += ''; + deviceHtml += ''; const iconUrl = imageHelper.getDeviceIcon(device); if (iconUrl) { diff --git a/src/controllers/dashboard/dlna/profiles.html b/src/controllers/dashboard/dlna/profiles.html index 615bb59ee8a..acfbc0c2a4f 100644 --- a/src/controllers/dashboard/dlna/profiles.html +++ b/src/controllers/dashboard/dlna/profiles.html @@ -8,7 +8,7 @@
diff --git a/src/controllers/dashboard/dlna/profiles.js b/src/controllers/dashboard/dlna/profiles.js index e507fc4e7c6..a0186c79ab8 100644 --- a/src/controllers/dashboard/dlna/profiles.js +++ b/src/controllers/dashboard/dlna/profiles.js @@ -41,7 +41,7 @@ import confirm from '../../../components/confirm/confirm'; html += '
'; html += ''; html += ''; @@ -79,10 +79,10 @@ import confirm from '../../../components/confirm/confirm'; function getTabs() { return [{ - href: 'dlnasettings.html', + href: '#!/dlnasettings.html', name: globalize.translate('Settings') }, { - href: 'dlnaprofiles.html', + href: '#!/dlnaprofiles.html', name: globalize.translate('TabProfiles') }]; } diff --git a/src/controllers/dashboard/dlna/settings.js b/src/controllers/dashboard/dlna/settings.js index 33c35b96444..4fa8467f363 100644 --- a/src/controllers/dashboard/dlna/settings.js +++ b/src/controllers/dashboard/dlna/settings.js @@ -38,10 +38,10 @@ import Dashboard from '../../../scripts/clientUtils'; function getTabs() { return [{ - href: 'dlnasettings.html', + href: '#!/dlnasettings.html', name: globalize.translate('Settings') }, { - href: 'dlnaprofiles.html', + href: '#!/dlnaprofiles.html', name: globalize.translate('TabProfiles') }]; } diff --git a/src/controllers/dashboard/encodingsettings.js b/src/controllers/dashboard/encodingsettings.js index dbb827e125c..c0ff10eeb72 100644 --- a/src/controllers/dashboard/encodingsettings.js +++ b/src/controllers/dashboard/encodingsettings.js @@ -145,13 +145,13 @@ import alert from '../../components/alert'; function getTabs() { return [{ - href: 'encodingsettings.html', + href: '#!/encodingsettings.html', name: globalize.translate('Transcoding') }, { - href: 'playbackconfiguration.html', + href: '#!/playbackconfiguration.html', name: globalize.translate('ButtonResume') }, { - href: 'streamingsettings.html', + href: '#!/streamingsettings.html', name: globalize.translate('TabStreaming') }]; } @@ -171,6 +171,10 @@ import alert from '../../components/alert'; page.querySelector('.fldOpenclDevice').classList.remove('hide'); page.querySelector('#txtOpenclDevice').setAttribute('required', 'required'); page.querySelector('.tonemappingOptions').classList.remove('hide'); + } else if (this.value == 'vaapi') { + page.querySelector('.fldOpenclDevice').classList.add('hide'); + page.querySelector('#txtOpenclDevice').removeAttribute('required'); + page.querySelector('.tonemappingOptions').classList.remove('hide'); } else { page.querySelector('.fldOpenclDevice').classList.add('hide'); page.querySelector('#txtOpenclDevice').removeAttribute('required'); diff --git a/src/controllers/dashboard/library.js b/src/controllers/dashboard/library.js index 8bac92d5d13..7faf9221155 100644 --- a/src/controllers/dashboard/library.js +++ b/src/controllers/dashboard/library.js @@ -359,16 +359,16 @@ import confirm from '../../components/confirm/confirm'; function getTabs() { return [{ - href: 'library.html', + href: '#!/library.html', name: globalize.translate('HeaderLibraries') }, { - href: 'librarydisplay.html', + href: '#!/librarydisplay.html', name: globalize.translate('Display') }, { - href: 'metadataimages.html', + href: '#!/metadataimages.html', name: globalize.translate('Metadata') }, { - href: 'metadatanfo.html', + href: '#!/metadatanfo.html', name: globalize.translate('TabNfoSettings') }]; } diff --git a/src/controllers/dashboard/librarydisplay.js b/src/controllers/dashboard/librarydisplay.js index 7e7bbb7cf8f..75abfe308f2 100644 --- a/src/controllers/dashboard/librarydisplay.js +++ b/src/controllers/dashboard/librarydisplay.js @@ -9,16 +9,16 @@ import Dashboard from '../../scripts/clientUtils'; function getTabs() { return [{ - href: 'library.html', + href: '#!/library.html', name: globalize.translate('HeaderLibraries') }, { - href: 'librarydisplay.html', + href: '#!/librarydisplay.html', name: globalize.translate('Display') }, { - href: 'metadataimages.html', + href: '#!/metadataimages.html', name: globalize.translate('Metadata') }, { - href: 'metadatanfo.html', + href: '#!/metadatanfo.html', name: globalize.translate('TabNfoSettings') }]; } diff --git a/src/controllers/dashboard/metadataImages.js b/src/controllers/dashboard/metadataImages.js index 6dda2f1ee69..dad9b26ed2d 100644 --- a/src/controllers/dashboard/metadataImages.js +++ b/src/controllers/dashboard/metadataImages.js @@ -52,16 +52,16 @@ import Dashboard from '../../scripts/clientUtils'; function getTabs() { return [{ - href: 'library.html', + href: '#!/library.html', name: globalize.translate('HeaderLibraries') }, { - href: 'librarydisplay.html', + href: '#!/librarydisplay.html', name: globalize.translate('Display') }, { - href: 'metadataimages.html', + href: '#!/metadataimages.html', name: globalize.translate('Metadata') }, { - href: 'metadatanfo.html', + href: '#!/metadatanfo.html', name: globalize.translate('TabNfoSettings') }]; } diff --git a/src/controllers/dashboard/metadatanfo.js b/src/controllers/dashboard/metadatanfo.js index d3777e47874..a8500f2735b 100644 --- a/src/controllers/dashboard/metadatanfo.js +++ b/src/controllers/dashboard/metadatanfo.js @@ -47,16 +47,16 @@ import alert from '../../components/alert'; function getTabs() { return [{ - href: 'library.html', + href: '#!/library.html', name: globalize.translate('HeaderLibraries') }, { - href: 'librarydisplay.html', + href: '#!/librarydisplay.html', name: globalize.translate('Display') }, { - href: 'metadataimages.html', + href: '#!/metadataimages.html', name: globalize.translate('Metadata') }, { - href: 'metadatanfo.html', + href: '#!/metadatanfo.html', name: globalize.translate('TabNfoSettings') }]; } diff --git a/src/controllers/dashboard/networking.html b/src/controllers/dashboard/networking.html index 60b2ffee372..ed6a7b00fca 100644 --- a/src/controllers/dashboard/networking.html +++ b/src/controllers/dashboard/networking.html @@ -105,7 +105,7 @@

${TabNetworking}

${LabelEnableAutomaticPortMapHelp}
-
+