mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
commit
f78354b6e9
190 changed files with 8354 additions and 8889 deletions
|
@ -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'
|
|
@ -13,5 +13,4 @@ pr:
|
|||
|
||||
jobs:
|
||||
- template: azure-pipelines-build.yml
|
||||
- template: azure-pipelines-lint.yml
|
||||
- template: azure-pipelines-package.yml
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
version: 1
|
||||
update_configs:
|
||||
- package_manager: "javascript"
|
||||
directory: "/"
|
||||
update_schedule: "weekly"
|
|
@ -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: [
|
||||
|
|
7
.github/dependabot.yaml
vendored
Normal file
7
.github/dependabot.yaml
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
95
.github/workflows/lint.yml
vendored
Normal file
95
.github/workflows/lint.yml
vendored
Normal file
|
@ -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
|
15
.github/workflows/merge-conflicts.yml
vendored
Normal file
15
.github/workflows/merge-conflicts.yml
vendored
Normal file
|
@ -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 }}
|
|
@ -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
|
||||
}
|
||||
}
|
8
.stylelintrc.scss.json
Normal file
8
.stylelintrc.scss.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": [ "./.stylelintrc.json" ],
|
||||
"plugins": [ "stylelint-scss" ],
|
||||
"rules": {
|
||||
"at-rule-no-unknown": null,
|
||||
"scss/at-rule-no-unknown": true
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
20
babel.config.js
Normal file
20
babel.config.js
Normal file
|
@ -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'
|
||||
]
|
||||
};
|
|
@ -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
|
||||
|
|
6
debian/changelog
vendored
6
debian/changelog
vendored
|
@ -1,3 +1,9 @@
|
|||
jellyfin-web (10.8.0-1) unstable; urgency=medium
|
||||
|
||||
* Forthcoming stable release
|
||||
|
||||
-- Jellyfin Packaging Team <packaging@jellyfin.org> Fri, 04 Dec 2020 21:58:23 -0500
|
||||
|
||||
jellyfin-web (10.7.0-1) unstable; urgency=medium
|
||||
|
||||
* Forthcoming stable release
|
||||
|
|
|
@ -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 <packaging@jellyfin.org>
|
||||
- Forthcoming stable release
|
||||
* Mon Jul 27 2020 Jellyfin Packaging Team <packaging@jellyfin.org>
|
||||
- Forthcoming stable release
|
||||
* Mon Mar 23 2020 Jellyfin Packaging Team <packaging@jellyfin.org>
|
||||
|
|
178
gulpfile.js
178
gulpfile.js
|
@ -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 `<script src="${filepath}" defer></script>`;
|
||||
}
|
||||
}
|
||||
))
|
||||
.pipe(dest('dist/'))
|
||||
.pipe(browserSync.stream());
|
||||
}
|
||||
|
||||
exports.default = series(clean, parallel(javascript, webpack, css, html, images, copy), injectBundle);
|
||||
exports.serve = series(exports.default, serve);
|
71
package.json
71
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\""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
}
|
||||
|
||||
html {
|
||||
@include font;
|
||||
@include font($size: 93%);
|
||||
text-size-adjust: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
|
|
@ -1057,7 +1057,7 @@ div.itemDetailGalleryLink.defaultCardBackground {
|
|||
.sectionTitleButton,
|
||||
.sectionTitleIconButton {
|
||||
margin-right: 0 !important;
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,7 +61,6 @@ 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'
|
||||
|
@ -88,7 +88,6 @@ import '../formdialog.css';
|
|||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* eslint-enable indent */
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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 + '</' + tagName + '>';
|
||||
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 + '</' + tagName + '>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 += '<div class="cardOverlayContainer itemAction" data-action="' + action + '">';
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -141,7 +141,7 @@ import '../elements/emby-itemscontainer/emby-itemscontainer';
|
|||
|
||||
if (result.Items.length) {
|
||||
if (html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">', !layoutManager.tv && options.Limit && result.Items.length >= options.Limit) {
|
||||
html += '<a is="emby-linkbutton" href="' + ('list.html?serverId=' + ApiClient.serverId() + '&type=' + section.types + '&IsFavorite=true') + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
|
||||
html += '<a is="emby-linkbutton" href="' + ('#!/list.html?serverId=' + ApiClient.serverId() + '&type=' + section.types + '&IsFavorite=true') + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
|
||||
html += '<h2 class="sectionTitle sectionTitle-cards">';
|
||||
html += globalize.translate(section.name);
|
||||
html += '</h2>';
|
||||
|
|
|
@ -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,7 +403,6 @@ import ServerConnections from '../ServerConnections';
|
|||
}
|
||||
|
||||
show() {
|
||||
return import('./filterdialog.template.html').then(({default: template}) => {
|
||||
return new Promise((resolve) => {
|
||||
const dlg = dialogHelper.createDialog({
|
||||
removeOnClose: true,
|
||||
|
@ -424,7 +424,6 @@ import ServerConnections from '../ServerConnections';
|
|||
loadDynamicFilters(dlg, apiClient, apiClient.getCurrentUserId(), this.options.query);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,7 +211,6 @@ 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
|
||||
|
@ -280,7 +280,6 @@ class FilterMenu {
|
|||
return resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,7 +89,6 @@ 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
|
||||
|
@ -141,7 +141,6 @@ function showEditor(options) {
|
|||
loadCategories(dlg, options);
|
||||
dialogHelper.open(dlg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -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,18 +1092,17 @@ 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, {
|
||||
dom.addEventListener(guideContext.querySelector('.guideVerticalScroller'), 'focus', onScrollerFocus, {
|
||||
capture: true,
|
||||
passive: true
|
||||
});
|
||||
|
@ -1112,43 +1111,43 @@ function Guide(options) {
|
|||
}
|
||||
|
||||
if (browser.iOS || browser.osx) {
|
||||
context.querySelector('.channelsContainer').classList.add('noRubberBanding');
|
||||
guideContext.querySelector('.channelsContainer').classList.add('noRubberBanding');
|
||||
|
||||
programGrid.classList.add('noRubberBanding');
|
||||
}
|
||||
|
||||
dom.addEventListener(programGrid, 'scroll', function (e) {
|
||||
onProgramGridScroll(context, this, timeslotHeaders);
|
||||
onProgramGridScroll(guideContext, this, timeslotHeaders);
|
||||
}, {
|
||||
passive: true
|
||||
});
|
||||
|
||||
dom.addEventListener(timeslotHeaders, 'scroll', function () {
|
||||
onTimeslotHeadersScroll(context, this);
|
||||
onTimeslotHeadersScroll(guideContext, this);
|
||||
}, {
|
||||
passive: true
|
||||
});
|
||||
|
||||
programGrid.addEventListener('click', onProgramGridClick);
|
||||
|
||||
context.querySelector('.btnNextPage').addEventListener('click', function () {
|
||||
guideContext.querySelector('.btnNextPage').addEventListener('click', function () {
|
||||
currentStartIndex += currentChannelLimit;
|
||||
reloadPage(context);
|
||||
reloadPage(guideContext);
|
||||
restartAutoRefresh();
|
||||
});
|
||||
|
||||
context.querySelector('.btnPreviousPage').addEventListener('click', function () {
|
||||
guideContext.querySelector('.btnPreviousPage').addEventListener('click', function () {
|
||||
currentStartIndex = Math.max(currentStartIndex - currentChannelLimit, 0);
|
||||
reloadPage(context);
|
||||
reloadPage(guideContext);
|
||||
restartAutoRefresh();
|
||||
});
|
||||
|
||||
context.querySelector('.btnGuideViewSettings').addEventListener('click', function () {
|
||||
guideContext.querySelector('.btnGuideViewSettings').addEventListener('click', function () {
|
||||
showViewSettings(self);
|
||||
restartAutoRefresh();
|
||||
});
|
||||
|
||||
context.querySelector('.guideDateTabs').addEventListener('tabchange', function (e) {
|
||||
guideContext.querySelector('.guideDateTabs').addEventListener('tabchange', function (e) {
|
||||
const allTabButtons = e.target.querySelectorAll('.guide-date-tab-button');
|
||||
|
||||
const tabButton = allTabButtons[parseInt(e.detail.selectedTabIndex)];
|
||||
|
@ -1177,12 +1176,12 @@ function Guide(options) {
|
|||
let startTimeOfDayMs = (date.getHours() * 60 * 60 * 1000);
|
||||
startTimeOfDayMs += (date.getMinutes() * 60 * 1000);
|
||||
|
||||
changeDate(context, date, scrollToTimeMs, scrollToTimeMs, startTimeOfDayMs, false);
|
||||
changeDate(guideContext, date, scrollToTimeMs, scrollToTimeMs, startTimeOfDayMs, false);
|
||||
}
|
||||
});
|
||||
|
||||
setScrollEvents(context, true);
|
||||
itemShortcuts.on(context);
|
||||
setScrollEvents(guideContext, true);
|
||||
itemShortcuts.on(guideContext);
|
||||
|
||||
Events.trigger(self, 'load');
|
||||
|
||||
|
@ -1192,7 +1191,6 @@ function Guide(options) {
|
|||
Events.on(serverNotifications, 'SeriesTimerCancelled', onSeriesTimerCancelled);
|
||||
|
||||
self.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
export default Guide;
|
||||
|
|
|
@ -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,12 +443,12 @@ import toast from '../toast/toast';
|
|||
}
|
||||
|
||||
function embed(options, self) {
|
||||
return import('./homeScreenSettings.template.html').then(({default: template}) => {
|
||||
let workingTemplate = template;
|
||||
for (let i = 1; i <= numConfigurableSections; i++) {
|
||||
template = template.replace(`{section${i}label}`, globalize.translate('LabelHomeScreenSectionValue', 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));
|
||||
|
@ -440,7 +465,6 @@ import toast from '../toast/toast';
|
|||
}
|
||||
|
||||
self.loadData(options.autoFocus);
|
||||
});
|
||||
}
|
||||
|
||||
class HomeScreenSettings {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,7 +317,6 @@ import ServerConnections from '../ServerConnections';
|
|||
function showEditor(itemId, serverId, itemType) {
|
||||
loading.show();
|
||||
|
||||
import('./imageDownloader.template.html').then(({default: template}) => {
|
||||
const apiClient = ServerConnections.getApiClient(serverId);
|
||||
|
||||
currentItemId = itemId;
|
||||
|
@ -353,7 +353,6 @@ import ServerConnections from '../ServerConnections';
|
|||
});
|
||||
|
||||
reloadBrowsableImages(editorContent, apiClient);
|
||||
});
|
||||
}
|
||||
|
||||
function onDialogClosed() {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,7 +129,6 @@ 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;
|
||||
|
||||
|
@ -171,7 +171,6 @@ import toast from '../toast/toast';
|
|||
dlg.querySelector('.btnCancel').addEventListener('click', () => {
|
||||
dialogHelper.close(dlg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function show(options) {
|
||||
|
|
|
@ -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,7 +420,6 @@ 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 = {
|
||||
|
@ -467,7 +467,6 @@ import confirm from '../confirm/confirm';
|
|||
dialogHelper.close(dlg);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function show (options) {
|
||||
|
|
|
@ -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 `<span class="mediaInfoLabel">${label}</span><span class="mediaInfoAttribute">${value}</span>`;
|
||||
}
|
||||
|
||||
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 */
|
||||
|
|
|
@ -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,7 +339,6 @@ import toast from '../toast/toast';
|
|||
function showEditor(itemId) {
|
||||
loading.show();
|
||||
|
||||
return import('./itemidentifier.template.html').then(({default: template}) => {
|
||||
const apiClient = getApiClient();
|
||||
|
||||
apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(item => {
|
||||
|
@ -399,7 +403,6 @@ import toast from '../toast/toast';
|
|||
showIdentificationForm(dlg, item);
|
||||
loading.hide();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onDialogClosed() {
|
||||
|
@ -416,7 +419,6 @@ import toast from '../toast/toast';
|
|||
currentItem = null;
|
||||
currentItemType = itemType;
|
||||
|
||||
return import('./itemidentifier.template.html').then(({default: template}) => {
|
||||
const dialogOptions = {
|
||||
size: 'small',
|
||||
removeOnClose: true,
|
||||
|
@ -463,7 +465,6 @@ import toast from '../toast/toast';
|
|||
dlg.classList.add('identifyDialog');
|
||||
|
||||
showIdentificationFormFindNew(dlg, itemName, itemYear, itemType);
|
||||
});
|
||||
}
|
||||
|
||||
function showIdentificationFormFindNew(dlg, itemName, itemYear, itemType) {
|
||||
|
|
|
@ -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'))];
|
||||
|
|
|
@ -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,7 +192,6 @@ export class showEditor {
|
|||
currentOptions = options;
|
||||
currentResolve = resolve;
|
||||
hasChanges = false;
|
||||
import('./mediaLibraryCreator.template.html').then(({default: template}) => {
|
||||
const dlg = dialogHelper.createDialog({
|
||||
size: 'small',
|
||||
modal: false,
|
||||
|
@ -213,7 +213,6 @@ export class showEditor {
|
|||
renderPaths(dlg);
|
||||
initLibraryOptions(dlg);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,7 +202,6 @@ export class showEditor {
|
|||
currentOptions = options;
|
||||
currentDeferred = deferred;
|
||||
hasChanges = false;
|
||||
import('./mediaLibraryEditor.template.html').then(({default: template}) => {
|
||||
const dlg = dialogHelper.createDialog({
|
||||
size: 'small',
|
||||
modal: false,
|
||||
|
@ -221,7 +221,6 @@ export class showEditor {
|
|||
dialogHelper.close(dlg);
|
||||
});
|
||||
refreshLibraryFromServer(dlg);
|
||||
});
|
||||
return deferred.promise();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,7 +1029,6 @@ 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
|
||||
|
@ -1069,7 +1069,6 @@ import { appRouter } from '../appRouter';
|
|||
init(dlg, ServerConnections.getApiClient(serverId));
|
||||
|
||||
reload(dlg, itemId, serverId);
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
|
@ -1083,7 +1082,6 @@ 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.querySelector('.formDialogFooter').classList.remove('formDialogFooter');
|
||||
|
@ -1098,7 +1096,6 @@ import { appRouter } from '../appRouter';
|
|||
|
||||
focusManager.autoFocus(elem);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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,7 +19,6 @@ 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
|
||||
|
@ -92,7 +92,6 @@ import '../formdialog.css';
|
|||
bubbles: true
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.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) {
|
||||
item = playlist[i];
|
||||
itemIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Item: item,
|
||||
Index: itemIndex
|
||||
};
|
||||
};
|
||||
|
||||
self.setCurrentPlaylistItem = function (playlistItemId, player) {
|
||||
player = player || self._currentPlayer;
|
||||
if (player && !enableLocalPlaylistManagement(player)) {
|
||||
return player.setCurrentPlaylistItem(playlistItemId);
|
||||
}
|
||||
|
||||
let newItem;
|
||||
let newItemIndex;
|
||||
const playlist = self._playQueueManager.getPlaylist();
|
||||
const newItem = self.getItemFromPlaylistItemId(playlistItemId);
|
||||
|
||||
for (let i = 0, length = playlist.length; i < length; i++) {
|
||||
if (playlist[i].PlaylistItemId === playlistItemId) {
|
||||
newItem = playlist[i];
|
||||
newItemIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (newItem.Item) {
|
||||
const newItemPlayOptions = newItem.Item.playOptions || getDefaultPlayOptions();
|
||||
|
||||
if (newItem) {
|
||||
const newItemPlayOptions = newItem.playOptions || getDefaultPlayOptions();
|
||||
|
||||
playInternal(newItem, newItemPlayOptions, function () {
|
||||
setPlaylistState(newItem.PlaylistItemId, newItemIndex);
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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,7 +279,6 @@ 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.querySelector('form').addEventListener('submit', onSubmit.bind(self));
|
||||
|
@ -292,7 +292,6 @@ import toast from '../toast/toast';
|
|||
if (options.autoFocus) {
|
||||
focusManager.autoFocus(options.element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class PlaybackSettings {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 += `<option value="queue">${globalize.translate('AddToPlayQueue')}</option>`;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
return showDialog(options);
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -15,56 +15,6 @@ export function getVideoQualityOptions(options) {
|
|||
|
||||
const qualityOptions = [];
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (maxAllowedWidth >= 1260) {
|
||||
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,
|
||||
|
@ -75,20 +25,43 @@ export function getVideoQualityOptions(options) {
|
|||
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 - 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 - 40 Mbps', maxHeight: 1080, bitrate: 40000000 });
|
||||
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: 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 });
|
||||
}
|
||||
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) {
|
||||
|
|
|
@ -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;
|
|
@ -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,7 +137,6 @@ function showEditor(itemId, serverId) {
|
|||
|
||||
loading.show();
|
||||
|
||||
import('./recordingcreator.template.html').then(({ default: template }) => {
|
||||
const dialogOptions = {
|
||||
removeOnClose: true,
|
||||
scrollY: false
|
||||
|
@ -194,7 +194,6 @@ function showEditor(itemId, serverId) {
|
|||
|
||||
dialogHelper.open(dlg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -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,7 +92,6 @@ function showEditor(itemId, serverId, options) {
|
|||
options = options || {};
|
||||
currentResolve = resolve;
|
||||
|
||||
import('./recordingeditor.template.html').then(({default: template}) => {
|
||||
const dialogOptions = {
|
||||
removeOnClose: true,
|
||||
scrollY: false
|
||||
|
@ -148,7 +148,6 @@ function showEditor(itemId, serverId, options) {
|
|||
|
||||
dialogHelper.open(dlg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -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,7 +120,6 @@ 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');
|
||||
|
@ -131,7 +131,6 @@ class RecordingEditor {
|
|||
|
||||
fetchData(self).then(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
hasChanged() {
|
||||
|
|
|
@ -180,4 +180,3 @@ export default {
|
|||
cancelTimerWithConfirmation: cancelTimerWithConfirmation,
|
||||
cancelSeriesTimerWithConfirmation: cancelSeriesTimerWithConfirmation
|
||||
};
|
||||
|
||||
|
|
|
@ -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,7 +152,6 @@ function embed(itemId, serverId, options) {
|
|||
loading.show();
|
||||
options = options || {};
|
||||
|
||||
import('./seriesrecordingeditor.template.html').then(({ default: template }) => {
|
||||
const dialogOptions = {
|
||||
removeOnClose: true,
|
||||
scrollY: false
|
||||
|
@ -182,7 +182,6 @@ function embed(itemId, serverId, options) {
|
|||
init(dlg);
|
||||
|
||||
reload(dlg, itemId);
|
||||
});
|
||||
}
|
||||
|
||||
function showEditor(itemId, serverId, options) {
|
||||
|
@ -193,7 +192,6 @@ function showEditor(itemId, serverId, options) {
|
|||
loading.show();
|
||||
options = options || {};
|
||||
|
||||
import('./seriesrecordingeditor.template.html').then(({ default: template }) => {
|
||||
const dialogOptions = {
|
||||
removeOnClose: true,
|
||||
scrollY: false
|
||||
|
@ -253,7 +251,6 @@ function showEditor(itemId, serverId, options) {
|
|||
|
||||
dialogHelper.open(dlg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -147,7 +147,7 @@ function updateNowPlayingInfo(context, state, serverId) {
|
|||
for (const artist of item.ArtistItems) {
|
||||
const artistName = artist.Name;
|
||||
const artistId = artist.Id;
|
||||
artistsSeries += `<a class="button-link emby-button" is="emby-linkbutton" href="details?id=${artistId}&serverId=${nowPlayingServerId}">${artistName}</a>`;
|
||||
artistsSeries += `<a class="button-link emby-button" is="emby-linkbutton" href="#!/details?id=${artistId}&serverId=${nowPlayingServerId}">${artistName}</a>`;
|
||||
if (artist !== item.ArtistItems.slice(-1)[0]) {
|
||||
artistsSeries += ', ';
|
||||
}
|
||||
|
@ -165,7 +165,7 @@ function updateNowPlayingInfo(context, state, serverId) {
|
|||
}
|
||||
}
|
||||
if (item.Album != null) {
|
||||
albumName = '<a class="button-link emby-button" is="emby-linkbutton" href="details?id=' + item.AlbumId + `&serverId=${nowPlayingServerId}">` + item.Album + '</a>';
|
||||
albumName = '<a class="button-link emby-button" is="emby-linkbutton" href="#!/details?id=' + item.AlbumId + `&serverId=${nowPlayingServerId}">` + item.Album + '</a>';
|
||||
}
|
||||
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 = '<a class="button-link emby-button" is="emby-linkbutton" href="details?id=' + item.SeasonId + `&serverId=${nowPlayingServerId}">${seasonName}</a>`;
|
||||
context.querySelector('.nowPlayingSeason').innerHTML = '<a class="button-link emby-button" is="emby-linkbutton" href="#!/details?id=' + item.SeasonId + `&serverId=${nowPlayingServerId}">${seasonName}</a>`;
|
||||
}
|
||||
if (item.SeriesName != null) {
|
||||
const seriesName = item.SeriesName;
|
||||
if (item.SeriesId != null) {
|
||||
context.querySelector('.nowPlayingSerie').innerHTML = '<a class="button-link emby-button" is="emby-linkbutton" href="details?id=' + item.SeriesId + `&serverId=${nowPlayingServerId}">${seriesName}</a>`;
|
||||
context.querySelector('.nowPlayingSerie').innerHTML = '<a class="button-link emby-button" is="emby-linkbutton" href="#!/details?id=' + item.SeriesId + `&serverId=${nowPlayingServerId}">${seriesName}</a>`;
|
||||
} else {
|
||||
context.querySelector('.nowPlayingSerie').innerHTML = seriesName;
|
||||
}
|
||||
|
|
|
@ -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,7 +62,6 @@ import './searchfields.css';
|
|||
}
|
||||
|
||||
function embed(elem, instance, options) {
|
||||
import('./searchfields.template.html').then(({default: template}) => {
|
||||
let html = globalize.translateHtml(template, 'core');
|
||||
|
||||
if (browser.tizen || browser.orsay) {
|
||||
|
@ -84,7 +84,6 @@ import './searchfields.css';
|
|||
txtSearch.addEventListener('input', onSearchInput.bind(instance));
|
||||
|
||||
instance.focus();
|
||||
});
|
||||
}
|
||||
|
||||
class SearchFields {
|
||||
|
|
|
@ -6,6 +6,7 @@ import '../../elements/emby-scroller/emby-scroller';
|
|||
import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import ServerConnections from '../ServerConnections';
|
||||
import template from './searchresults.template.html';
|
||||
|
||||
/* eslint-disable indent */
|
||||
|
||||
|
@ -585,19 +586,18 @@ import ServerConnections from '../ServerConnections';
|
|||
}
|
||||
|
||||
function embed(elem, instance, options) {
|
||||
import('./searchresults.template.html').then(({default: template}) => {
|
||||
let workingTemplate = template;
|
||||
if (!enableScrollX()) {
|
||||
template = replaceAll(template, 'data-horizontal="true"', 'data-horizontal="false"');
|
||||
template = replaceAll(template, 'itemsContainer scrollSlider', 'itemsContainer scrollSlider vertical-wrap');
|
||||
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.classList.add('searchResults');
|
||||
instance.search('');
|
||||
});
|
||||
}
|
||||
|
||||
class SearchResults {
|
||||
|
|
|
@ -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');
|
||||
|
||||
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({
|
||||
|
|
|
@ -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,7 +45,6 @@ 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
|
||||
|
@ -103,7 +103,6 @@ class SortMenu {
|
|||
reject();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,7 +159,6 @@ 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');
|
||||
|
||||
|
@ -214,7 +214,6 @@ function embed(options, self) {
|
|||
if (options.autoFocus) {
|
||||
focusManager.autoFocus(options.element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class SubtitleSettings {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<button type="button" is="paper-icon-button-light" class="subtitleSync-closeButton"><span class="material-icons close"></span></button>
|
||||
<div class="subtitleSyncTextField" contenteditable="true" spellcheck="false">0s</div>
|
||||
<div class="sliderContainer subtitleSyncSliderContainer">
|
||||
<input is="emby-slider" type="range" step="1" min="0" max="100" value="50" class="subtitleSyncSlider" data-slider-keep-progress="true" />
|
||||
<input is="emby-slider" type="range" step=".1" min="0" max="100" value="50" class="subtitleSyncSlider" data-slider-keep-progress="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
221
src/components/syncPlay/core/Controller.js
Normal file
221
src/components/syncPlay/core/Controller.js
Normal file
|
@ -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;
|
238
src/components/syncPlay/core/Helper.js
Normal file
238
src/components/syncPlay/core/Helper.js
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
481
src/components/syncPlay/core/Manager.js
Normal file
481
src/components/syncPlay/core/Manager.js
Normal file
|
@ -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;
|
587
src/components/syncPlay/core/PlaybackCore.js
Normal file
587
src/components/syncPlay/core/PlaybackCore.js
Normal file
|
@ -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;
|
371
src/components/syncPlay/core/QueueCore.js
Normal file
371
src/components/syncPlay/core/QueueCore.js
Normal file
|
@ -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;
|
16
src/components/syncPlay/core/index.js
Normal file
16
src/components/syncPlay/core/index.js
Normal file
|
@ -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
|
||||
}
|
||||
};
|
305
src/components/syncPlay/core/players/GenericPlayer.js
Normal file
305
src/components/syncPlay/core/players/GenericPlayer.js
Normal file
|
@ -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;
|
71
src/components/syncPlay/core/players/PlayerFactory.js
Normal file
71
src/components/syncPlay/core/players/PlayerFactory.js
Normal file
|
@ -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;
|
|
@ -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,21 +32,21 @@ class Measurement {
|
|||
}
|
||||
|
||||
/**
|
||||
* Time offset from server.
|
||||
* Time offset from remote entity, in milliseconds.
|
||||
*/
|
||||
getOffset() {
|
||||
return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get round-trip delay.
|
||||
* Get round-trip delay, in milliseconds.
|
||||
*/
|
||||
getDelay() {
|
||||
return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ping time.
|
||||
* Get ping time, in milliseconds.
|
||||
*/
|
||||
getPing() {
|
||||
return this.getDelay() / 2;
|
||||
|
@ -55,10 +54,11 @@ class Measurement {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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,7 +76,7 @@ class TimeSyncManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets time offset with server.
|
||||
* Gets time offset with remote entity, in milliseconds.
|
||||
* @returns {number} The time offset.
|
||||
*/
|
||||
getTimeOffset() {
|
||||
|
@ -84,7 +84,7 @@ class TimeSyncManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets ping time to server.
|
||||
* Gets ping time to remote entity, in milliseconds.
|
||||
* @returns {number} The ping time.
|
||||
*/
|
||||
getPing() {
|
||||
|
@ -92,7 +92,7 @@ class TimeSyncManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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,31 +101,46 @@ 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);
|
||||
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 server
|
||||
// Avoid overloading network.
|
||||
if (this.pings >= GreedyPingCount) {
|
||||
this.pollingInterval = PollingIntervalLowProfile;
|
||||
} else {
|
||||
|
@ -133,15 +148,15 @@ class TimeSyncManager {
|
|||
}
|
||||
|
||||
Events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]);
|
||||
});
|
||||
}).catch((error) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a failed ping request.
|
||||
* @param {Object} error The error.
|
||||
*/
|
||||
onPingRequestErrorCallback(error) {
|
||||
console.error(error);
|
||||
Events.trigger(this, 'update', [error, null, null]);
|
||||
}).finally(() => {
|
||||
this.requestPing();
|
||||
});
|
||||
}, this.pollingInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
78
src/components/syncPlay/core/timeSync/TimeSyncCore.js
Normal file
78
src/components/syncPlay/core/timeSync/TimeSyncCore.js
Normal file
|
@ -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;
|
39
src/components/syncPlay/core/timeSync/TimeSyncServer.js
Normal file
39
src/components/syncPlay/core/timeSync/TimeSyncServer.js
Normal file
|
@ -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;
|
|
@ -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')
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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();
|
189
src/components/syncPlay/ui/groupSelectionMenu.js
Normal file
189
src/components/syncPlay/ui/groupSelectionMenu.js
Normal file
|
@ -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;
|
19
src/components/syncPlay/ui/players/HtmlAudioPlayer.js
Normal file
19
src/components/syncPlay/ui/players/HtmlAudioPlayer.js
Normal file
|
@ -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;
|
155
src/components/syncPlay/ui/players/HtmlVideoPlayer.js
Normal file
155
src/components/syncPlay/ui/players/HtmlVideoPlayer.js
Normal file
|
@ -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;
|
444
src/components/syncPlay/ui/players/NoActivePlayer.js
Normal file
444
src/components/syncPlay/ui/players/NoActivePlayer.js
Normal file
|
@ -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;
|
202
src/components/syncPlay/ui/players/QueueManager.js
Normal file
202
src/components/syncPlay/ui/players/QueueManager.js
Normal file
|
@ -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;
|
34
src/components/syncPlay/ui/syncPlayToasts.js
Normal file
34
src/components/syncPlay/ui/syncPlayToasts.js
Normal file
|
@ -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;
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,7 +60,6 @@ class ViewSettings {
|
|||
}
|
||||
show(options) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
import('./viewSettings.template.html').then(({default: template}) => {
|
||||
const dialogOptions = {
|
||||
removeOnClose: true,
|
||||
scrollY: false
|
||||
|
@ -135,7 +135,6 @@ class ViewSettings {
|
|||
reject();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"includeCorsCredentials": false,
|
||||
"multiserver": false,
|
||||
"themes": [
|
||||
{
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="dashboardSections" style="padding-top:.5em;">
|
||||
<div class="dashboardColumn dashboardColumn-2-60 dashboardColumn-3-46">
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="dashboardgeneral.html" class="button-flat sectionTitleTextButton">
|
||||
<a is="emby-linkbutton" href="#!/dashboardgeneral.html" class="button-flat sectionTitleTextButton">
|
||||
<h3>${TabServer}</h3>
|
||||
<span class="material-icons chevron_right"></span>
|
||||
</a>
|
||||
|
@ -35,7 +35,7 @@
|
|||
</div>
|
||||
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="devices.html" class="button-flat sectionTitleTextButton">
|
||||
<a is="emby-linkbutton" href="#!/devices.html" class="button-flat sectionTitleTextButton">
|
||||
<h3>${HeaderActiveDevices}</h3>
|
||||
<span class="material-icons chevron_right"></span>
|
||||
</a>
|
||||
|
@ -46,7 +46,7 @@
|
|||
|
||||
<div class="dashboardColumn dashboardColumn-2-40 dashboardColumn-3-27">
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="serveractivity.html?useractivity=true" class="button-flat sectionTitleTextButton">
|
||||
<a is="emby-linkbutton" href="#!/serveractivity.html?useractivity=true" class="button-flat sectionTitleTextButton">
|
||||
<h3>${HeaderActivity}</h3>
|
||||
<span class="material-icons chevron_right"></span>
|
||||
</a>
|
||||
|
@ -63,7 +63,7 @@
|
|||
</div>
|
||||
|
||||
<div class="dashboardSection serverActivitySection hide activityContainer">
|
||||
<a is="emby-linkbutton" href="serveractivity.html?useractivity=false" class="button-flat sectionTitleTextButton">
|
||||
<a is="emby-linkbutton" href="#!/serveractivity.html?useractivity=false" class="button-flat sectionTitleTextButton">
|
||||
<h3>${Alerts}</h3>
|
||||
<span class="material-icons chevron_right"></span>
|
||||
</a>
|
||||
|
@ -72,7 +72,7 @@
|
|||
</div>
|
||||
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="dashboardgeneral.html" class="button-flat sectionTitleTextButton">
|
||||
<a is="emby-linkbutton" href="#!/dashboardgeneral.html" class="button-flat sectionTitleTextButton">
|
||||
<h3>${HeaderPaths}</h3>
|
||||
<span class="material-icons chevron_right"></span>
|
||||
</a>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<div class="sectionTitleContainer sectionTitleContainer-cards flex align-items-center">
|
||||
<h2 class="sectionTitle sectionTitle-cards">${HeaderDevices}</h2>
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://docs.jellyfin.org/general/server/devices.html">${Help}</a>
|
||||
<button id="deviceDeleteAll" is="emby-button" type="button" class="raised button-alt">${DeleteAll}</button>
|
||||
<button id="deviceDeleteAll" is="emby-button" type="button" class="raised button-alt headerHelpButton">${DeleteAll}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div is="emby-itemscontainer" class="devicesList vertical-wrap" data-multiselect="false"></div>
|
||||
|
|
|
@ -96,7 +96,7 @@ import confirm from '../../../components/confirm/confirm';
|
|||
deviceHtml += '<div class="cardBox visualCardBox">';
|
||||
deviceHtml += '<div class="cardScalable">';
|
||||
deviceHtml += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
deviceHtml += '<a is="emby-linkbutton" href="' + (canEdit ? 'device.html?id=' + device.Id : '#') + '" class="cardContent cardImageContainer">';
|
||||
deviceHtml += '<a is="emby-linkbutton" href="' + (canEdit ? '#!/device.html?id=' + device.Id : '#') + '" class="cardContent cardImageContainer">';
|
||||
const iconUrl = imageHelper.getDeviceIcon(device);
|
||||
|
||||
if (iconUrl) {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<div class="verticalSection verticalSection-extrabottompadding">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${HeaderCustomDlnaProfiles}</h2>
|
||||
<a is="emby-linkbutton" href="dlnaprofile.html" class="fab submit" style="margin:0 0 0 1em">
|
||||
<a is="emby-linkbutton" href="#!/dlnaprofile.html" class="fab submit" style="margin:0 0 0 1em">
|
||||
<span class="material-icons add"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -41,7 +41,7 @@ import confirm from '../../../components/confirm/confirm';
|
|||
html += '<div class="listItem listItem-border">';
|
||||
html += '<span class="listItemIcon material-icons live_tv"></span>';
|
||||
html += '<div class="listItemBody two-line">';
|
||||
html += "<a is='emby-linkbutton' style='padding:0;margin:0;' data-ripple='false' class='clearLink' href='dlnaprofile.html?id=" + profile.Id + "'>";
|
||||
html += "<a is='emby-linkbutton' style='padding:0;margin:0;' data-ripple='false' class='clearLink' href='#!/dlnaprofile.html?id=" + profile.Id + "'>";
|
||||
html += '<div>' + profile.Name + '</div>';
|
||||
html += '</a>';
|
||||
html += '</div>';
|
||||
|
@ -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')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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')
|
||||
}];
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue