Merge branch 'master' into sorted-media-sources

This commit is contained in:
DesertCookie 2020-06-02 17:05:47 +02:00 committed by GitHub
commit f7158c7fad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
201 changed files with 8251 additions and 4119 deletions

View File

@ -21,8 +21,6 @@ jobs:
BuildConfiguration: development
Production:
BuildConfiguration: production
Standalone:
BuildConfiguration: standalone
pool:
vmImage: 'ubuntu-latest'
@ -49,13 +47,9 @@ jobs:
condition: eq(variables['BuildConfiguration'], 'development')
- script: 'yarn build:production'
displayName: 'Build Bundle'
displayName: 'Build Production'
condition: eq(variables['BuildConfiguration'], 'production')
- script: 'yarn build:standalone'
displayName: 'Build Standalone'
condition: eq(variables['BuildConfiguration'], 'standalone')
- script: 'test -d dist'
displayName: 'Check Build'

View File

@ -27,28 +27,30 @@ module.exports = {
'plugin:compat/recommended'
],
rules: {
'block-spacing': ["error"],
'brace-style': ["error"],
'comma-dangle': ["error", "never"],
'comma-spacing': ["error"],
'eol-last': ["error"],
'indent': ["error", 4, { "SwitchCase": 1 }],
'keyword-spacing': ["error"],
'max-statements-per-line': ["error"],
'no-floating-decimal': ["error"],
'no-multi-spaces': ["error"],
'no-multiple-empty-lines': ["error", { "max": 1 }],
'no-trailing-spaces': ["error"],
'one-var': ["error", "never"],
'quotes': ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": false }],
'semi': ["error"],
'space-before-blocks': ["error"]
'block-spacing': ['error'],
'brace-style': ['error'],
'comma-dangle': ['error', 'never'],
'comma-spacing': ['error'],
'eol-last': ['error'],
'indent': ['error', 4, { 'SwitchCase': 1 }],
'keyword-spacing': ['error'],
'max-statements-per-line': ['error'],
'no-floating-decimal': ['error'],
'no-multi-spaces': ['error'],
'no-multiple-empty-lines': ['error', { 'max': 1 }],
'no-trailing-spaces': ['error'],
'one-var': ['error', 'never'],
'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
'semi': ['error'],
'space-before-blocks': ['error'],
'space-infix-ops': 'error'
},
overrides: [
{
files: [
'./src/**/*.js'
],
parser: 'babel-eslint',
env: {
node: false,
amd: true,
@ -96,11 +98,11 @@ module.exports = {
},
rules: {
// TODO: Fix warnings and remove these rules
'no-redeclare': ["warn"],
'no-unused-vars': ["warn"],
'no-useless-escape': ["warn"],
'no-redeclare': ['warn'],
'no-unused-vars': ['warn'],
'no-useless-escape': ['warn'],
// TODO: Remove after ES6 migration is complete
'import/no-unresolved': ["off"]
'import/no-unresolved': ['off']
},
settings: {
polyfills: [

5
.gitignore vendored
View File

@ -8,4 +8,7 @@ node_modules
# ide
.idea
.vscode
.vscode
#log
yarn-error.log

View File

@ -36,6 +36,7 @@
- [MrTimscampi](https://github.com/MrTimscampi)
- [Sarab Singh](https://github.com/sarab97)
- [DesertCookie](https://github.com/desertcookie)
- [Andrei Oanca](https://github.com/OancaAndrei)
# Emby Contributors

View File

@ -44,7 +44,7 @@ Jellyfin Web is the frontend used for most of the clients available for end user
### Dependencies
- Yarn
- [Yarn 1.22.4](https://classic.yarnpkg.com/en/docs/install)
- Gulp-cli
### Getting Started
@ -78,4 +78,4 @@ Jellyfin Web is the frontend used for most of the clients available for end user
```sh
yarn build:standalone
```
```

View File

@ -16,7 +16,6 @@ const stream = require('webpack-stream');
const inject = require('gulp-inject');
const postcss = require('gulp-postcss');
const sass = require('gulp-sass');
const gulpif = require('gulp-if');
const lazypipe = require('lazypipe');
sass.compiler = require('node-sass');
@ -45,7 +44,7 @@ const options = {
query: ['src/**/*.png', 'src/**/*.jpg', 'src/**/*.gif', 'src/**/*.svg']
},
copy: {
query: ['src/**/*.json', 'src/**/*.ico']
query: ['src/**/*.json', 'src/**/*.ico', 'src/**/*.mp3']
},
injectBundle: {
query: 'src/index.html'
@ -68,7 +67,7 @@ function serve() {
}
});
watch(options.apploader.query, apploader(true));
watch(options.apploader.query, apploader());
watch('src/bundle.js', webpack);
@ -131,18 +130,12 @@ function javascript(query) {
.pipe(browserSync.stream());
}
function apploader(standalone) {
function task() {
return src(options.apploader.query, { base: './src/' })
.pipe(gulpif(standalone, concat('scripts/apploader.js')))
.pipe(pipelineJavascript())
.pipe(dest('dist/'))
.pipe(browserSync.stream());
}
task.displayName = 'apploader';
return task;
function apploader() {
return src(options.apploader.query, { base: './src/' })
.pipe(concat('scripts/apploader.js'))
.pipe(pipelineJavascript())
.pipe(dest('dist/'))
.pipe(browserSync.stream());
}
function webpack() {
@ -181,12 +174,6 @@ function copy(query) {
.pipe(browserSync.stream());
}
function copyIndex() {
return src(options.injectBundle.query, { base: './src/' })
.pipe(dest('dist/'))
.pipe(browserSync.stream());
}
function injectBundle() {
return src(options.injectBundle.query, { base: './src/' })
.pipe(inject(
@ -196,10 +183,5 @@ function injectBundle() {
.pipe(browserSync.stream());
}
function build(standalone) {
return series(clean, parallel(javascript, apploader(standalone), webpack, css, html, images, copy));
}
exports.default = series(build(false), copyIndex);
exports.standalone = series(build(true), injectBundle);
exports.serve = series(exports.standalone, serve);
exports.default = series(clean, parallel(javascript, apploader, webpack, css, html, images, copy), injectBundle);
exports.serve = series(exports.default, serve);

View File

@ -5,27 +5,29 @@
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/core": "^7.10.2",
"@babel/plugin-proposal-class-properties": "^7.10.1",
"@babel/plugin-proposal-private-methods": "^7.10.1",
"@babel/plugin-transform-modules-amd": "^7.9.6",
"@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.8.6",
"autoprefixer": "^9.7.6",
"@babel/preset-env": "^7.10.2",
"autoprefixer": "^9.8.0",
"babel-eslint": "^11.0.0-beta.2",
"babel-loader": "^8.0.6",
"browser-sync": "^2.26.7",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2",
"cssnano": "^4.1.10",
"del": "^5.1.0",
"eslint": "^6.8.0",
"eslint-plugin-compat": "^3.5.1",
"eslint-plugin-eslint-comments": "^3.1.2",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-promise": "^4.2.1",
"file-loader": "^6.0.0",
"gulp": "^4.0.2",
"gulp-babel": "^8.0.0",
"gulp-cli": "^2.2.0",
"gulp-cli": "^2.2.1",
"gulp-concat": "^2.6.1",
"gulp-htmlmin": "^5.0.1",
"gulp-if": "^3.0.0",
@ -42,14 +44,11 @@
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0",
"style-loader": "^1.1.3",
"stylelint": "^13.3.3",
"stylelint": "^13.5.0",
"stylelint-config-rational-order": "^0.1.2",
"stylelint-no-browser-hacks": "^1.2.1",
"stylelint-order": "^4.0.0",
"webpack": "^4.41.5",
"webpack-cli": "^3.3.10",
"webpack-concat-plugin": "^3.0.0",
"webpack-dev-server": "^3.11.0",
"webpack-merge": "^4.2.2",
"webpack-stream": "^5.2.1"
},
@ -57,15 +56,16 @@
"alameda": "^1.4.0",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"core-js": "^3.6.5",
"date-fns": "^2.13.0",
"date-fns": "^2.14.0",
"document-register-element": "^1.14.3",
"epubjs": "^0.3.85",
"fast-text-encoding": "^1.0.1",
"flv.js": "^1.5.0",
"headroom.js": "^0.11.0",
"hls.js": "^0.13.1",
"howler": "^2.1.3",
"howler": "^2.2.0",
"intersection-observer": "^0.10.0",
"jellyfin-apiclient": "^1.1.1",
"jellyfin-apiclient": "^1.2.0",
"jellyfin-noto": "https://github.com/jellyfin/jellyfin-noto",
"jquery": "^3.5.1",
"jstree": "^3.3.7",
@ -76,9 +76,9 @@
"query-string": "^6.11.1",
"resize-observer-polyfill": "^1.5.1",
"screenfull": "^5.0.2",
"shaka-player": "^2.5.11",
"shaka-player": "^2.5.12",
"sortablejs": "^1.10.2",
"swiper": "^5.3.7",
"swiper": "^5.4.1",
"webcomponents.js": "^0.7.24",
"whatwg-fetch": "^3.0.0"
},
@ -91,24 +91,37 @@
"test": [
"src/components/autoFocuser.js",
"src/components/cardbuilder/cardBuilder.js",
"src/components/filedownloader.js",
"src/scripts/fileDownloader.js",
"src/components/images/imageLoader.js",
"src/components/lazyloader/lazyloader-intersectionobserver.js",
"src/components/lazyLoader/lazyLoaderIntersectionObserver.js",
"src/components/playback/mediasession.js",
"src/components/sanatizefilename.js",
"src/components/scrollManager.js",
"src/components/bookPlayer/plugin.js",
"src/components/bookPlayer/tableOfContent.js",
"src/components/syncplay/playbackPermissionManager.js",
"src/components/syncplay/groupSelectionMenu.js",
"src/components/syncplay/timeSyncManager.js",
"src/components/syncplay/syncPlayManager.js",
"src/scripts/dfnshelper.js",
"src/scripts/dom.js",
"src/scripts/filesystem.js",
"src/scripts/imagehelper.js",
"src/scripts/inputManager.js",
"src/scripts/keyboardnavigation.js",
"src/scripts/deleteHelper.js",
"src/components/actionSheet/actionSheet.js",
"src/components/playmenu.js",
"src/components/indicators/indicators.js",
"src/components/photoPlayer/plugin.js",
"src/scripts/keyboardNavigation.js",
"src/scripts/settings/appSettings.js",
"src/scripts/settings/userSettings.js",
"src/scripts/settings/webSettings.js"
],
"plugins": [
"@babel/plugin-transform-modules-amd"
"@babel/plugin-transform-modules-amd",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-private-methods"
]
}
]
@ -133,7 +146,6 @@
"prepare": "gulp --production",
"build:development": "gulp --development",
"build:production": "gulp --production",
"build:standalone": "gulp standalone --development",
"lint": "eslint \".\"",
"stylelint": "stylelint \"src/**/*.css\""
}

View File

@ -15,6 +15,8 @@ print(langlst)
input('press enter to continue')
keysus = []
missing = []
with open(langdir + '/' + 'en-us.json') as en:
langus = json.load(en)
for key in langus:
@ -32,10 +34,19 @@ for lang in langlst:
for key in langjson:
if key in keysus:
langjnew[key] = langjson[key]
elif key not in missing:
missing.append(key)
f.seek(0)
f.write(json.dumps(langjnew, indent=inde, sort_keys=False, ensure_ascii=False))
f.write('\n')
f.truncate()
f.close()
print(missing)
print('LENGTH: ' + str(len(missing)))
with open('missing.txt', 'w') as out:
for item in missing:
out.write(item + '\n')
out.close()
print('DONE')

View File

@ -34,7 +34,7 @@ for lang in langlst:
print(dep)
print('LENGTH: ' + str(len(dep)))
with open('scout.txt', 'w') as out:
with open('unused.txt', 'w') as out:
for item in dep:
out.write(item + '\n')
out.close()

Binary file not shown.

View File

@ -120,3 +120,11 @@ div[data-role=page] {
.headroom--unpinned {
transform: translateY(-100%);
}
.force-scroll {
overflow-y: scroll;
}
.hide-scroll {
overflow-y: hidden;
}

View File

@ -30,7 +30,7 @@
opacity: 0;
}
.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton) {
.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton):not(.headerSyncButton) {
display: none;
}

View File

@ -102,6 +102,11 @@ _define('jellyfin-noto', function () {
return noto;
});
var epubjs = require('epubjs');
_define('epubjs', function () {
return epubjs;
});
// page.js
var page = require('page');
_define('page', function() {

View File

@ -50,7 +50,7 @@ define(['dialogHelper', 'datetime', 'globalize', 'emby-select', 'paper-icon-butt
show: function (options) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'components/accessschedule/accessschedule.template.html', true);
xhr.open('GET', 'components/accessSchedule/accessSchedule.template.html', true);
xhr.onload = function (e) {
var template = this.response;
@ -72,12 +72,12 @@ define(['dialogHelper', 'datetime', 'globalize', 'emby-select', 'paper-icon-butt
reject();
}
});
dlg.querySelector('.btnCancel').addEventListener('click', function (e) {
dlg.querySelector('.btnCancel').addEventListener('click', function () {
dialogHelper.close(dlg);
});
dlg.querySelector('form').addEventListener('submit', function (e) {
dlg.querySelector('form').addEventListener('submit', function (event) {
submitSchedule(dlg, options);
e.preventDefault();
event.preventDefault();
return false;
});
};

View File

@ -0,0 +1,340 @@
import dialogHelper from 'dialogHelper';
import layoutManager from 'layoutManager';
import globalize from 'globalize';
import dom from 'dom';
import 'emby-button';
import 'css!./actionSheet';
import 'material-icons';
import 'scrollStyles';
import 'listViewStyle';
function getOffsets(elems) {
let results = [];
if (!document) {
return results;
}
for (let elem of elems) {
let box = elem.getBoundingClientRect();
results.push({
top: box.top,
left: box.left,
width: box.width,
height: box.height
});
}
return results;
}
function getPosition(options, dlg) {
const windowSize = dom.getWindowSize();
const windowHeight = windowSize.innerHeight;
const windowWidth = windowSize.innerWidth;
let pos = getOffsets([options.positionTo])[0];
if (options.positionY !== 'top') {
pos.top += (pos.height || 0) / 2;
}
pos.left += (pos.width || 0) / 2;
const height = dlg.offsetHeight || 300;
const width = dlg.offsetWidth || 160;
// Account for popup size
pos.top -= height / 2;
pos.left -= width / 2;
// Avoid showing too close to the bottom
const overflowX = pos.left + width - windowWidth;
const overflowY = pos.top + height - windowHeight;
if (overflowX > 0) {
pos.left -= (overflowX + 20);
}
if (overflowY > 0) {
pos.top -= (overflowY + 20);
}
pos.top += (options.offsetTop || 0);
pos.left += (options.offsetLeft || 0);
// Do some boundary checking
pos.top = Math.max(pos.top, 10);
pos.left = Math.max(pos.left, 10);
return pos;
}
function centerFocus(elem, horiz, on) {
require(['scrollHelper'], function (scrollHelper) {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
});
}
export function show(options) {
// items
// positionTo
// showCancel
// title
let dialogOptions = {
removeOnClose: true,
enableHistory: options.enableHistory,
scrollY: false
};
let isFullscreen;
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
isFullscreen = true;
dialogOptions.autoFocus = true;
} else {
dialogOptions.modal = false;
dialogOptions.entryAnimation = options.entryAnimation;
dialogOptions.exitAnimation = options.exitAnimation;
dialogOptions.entryAnimationDuration = options.entryAnimationDuration || 140;
dialogOptions.exitAnimationDuration = options.exitAnimationDuration || 100;
dialogOptions.autoFocus = false;
}
let dlg = dialogHelper.createDialog(dialogOptions);
if (isFullscreen) {
dlg.classList.add('actionsheet-fullscreen');
} else {
dlg.classList.add('actionsheet-not-fullscreen');
}
dlg.classList.add('actionSheet');
if (options.dialogClass) {
dlg.classList.add(options.dialogClass);
}
let html = '';
const scrollClassName = layoutManager.tv ? 'scrollY smoothScrollY hiddenScrollY' : 'scrollY';
let style = '';
// Admittedly a hack but right now the scrollbar is being factored into the width which is causing truncation
if (options.items.length > 20) {
const minWidth = dom.getWindowSize().innerWidth >= 300 ? 240 : 200;
style += 'min-width:' + minWidth + 'px;';
}
let renderIcon = false;
let icons = [];
let itemIcon;
for (let item of options.items) {
itemIcon = item.icon || (item.selected ? 'check' : null);
if (itemIcon) {
renderIcon = true;
}
icons.push(itemIcon || '');
}
if (layoutManager.tv) {
html += `<button is="paper-icon-button-light" class="btnCloseActionSheet hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons arrow_back"></span>
</button>`;
}
// If any items have an icon, give them all an icon just to make sure they're all lined up evenly
const center = options.title && (!renderIcon /*|| itemsWithIcons.length != options.items.length*/);
if (center || layoutManager.tv) {
html += '<div class="actionSheetContent actionSheetContent-centered">';
} else {
html += '<div class="actionSheetContent">';
}
if (options.title) {
html += '<h1 class="actionSheetTitle">' + options.title + '</h1>';
}
if (options.text) {
html += '<p class="actionSheetText">' + options.text + '</p>';
}
let scrollerClassName = 'actionSheetScroller';
if (layoutManager.tv) {
scrollerClassName += ' actionSheetScroller-tv focuscontainer-x focuscontainer-y';
}
html += '<div class="' + scrollerClassName + ' ' + scrollClassName + '" style="' + style + '">';
let menuItemClass = 'listItem listItem-button actionSheetMenuItem';
if (options.border || options.shaded) {
menuItemClass += ' listItem-border';
}
if (options.menuItemClass) {
menuItemClass += ' ' + options.menuItemClass;
}
if (layoutManager.tv) {
menuItemClass += ' listItem-focusscale';
}
if (layoutManager.mobile) {
menuItemClass += ' actionsheet-xlargeFont';
}
// 'options.items' is HTMLOptionsCollection, so no fancy loops
for (let i = 0; i < options.items.length; i++) {
const item = options.items[i];
if (item.divider) {
html += '<div class="actionsheetDivider"></div>';
continue;
}
const autoFocus = item.selected && layoutManager.tv ? ' autoFocus' : '';
// Check for null in case int 0 was passed in
const optionId = item.id == null || item.id === '' ? item.value : item.id;
html += '<button' + autoFocus + ' is="emby-button" type="button" class="' + menuItemClass + '" data-id="' + optionId + '">';
itemIcon = icons[i];
if (itemIcon) {
html += `<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons ${itemIcon}"></span>`;
} else if (renderIcon && !center) {
html += '<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" style="visibility:hidden;"></span>';
}
html += '<div class="listItemBody actionsheetListItemBody">';
html += '<div class="listItemBodyText actionSheetItemText">';
html += (item.name || item.textContent || item.innerText);
html += '</div>';
if (item.secondaryText) {
html += `<div class="listItemBodyText secondary">${item.secondaryText}</div>`;
}
html += '</div>';
if (item.asideText) {
html += `<div class="listItemAside actionSheetItemAsideText">${item.asideText}</div>`;
}
html += '</button>';
}
if (options.showCancel) {
html += '<div class="buttons">';
html += `<button is="emby-button" type="button" class="btnCloseActionSheet">${globalize.translate('ButtonCancel')}</button>`;
html += '</div>';
}
html += '</div>';
dlg.innerHTML = html;
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.actionSheetScroller'), false, true);
}
let btnCloseActionSheet = dlg.querySelector('.btnCloseActionSheet');
if (btnCloseActionSheet) {
btnCloseActionSheet.addEventListener('click', function () {
dialogHelper.close(dlg);
});
}
// Seeing an issue in some non-chrome browsers where this is requiring a double click
//var eventName = browser.firefox ? 'mousedown' : 'click';
let selectedId;
let timeout;
if (options.timeout) {
timeout = setTimeout(function () {
dialogHelper.close(dlg);
}, options.timeout);
}
return new Promise(function (resolve, reject) {
let isResolved;
dlg.addEventListener('click', function (e) {
const actionSheetMenuItem = dom.parentWithClass(e.target, 'actionSheetMenuItem');
if (actionSheetMenuItem) {
selectedId = actionSheetMenuItem.getAttribute('data-id');
if (options.resolveOnClick) {
if (options.resolveOnClick.indexOf) {
if (options.resolveOnClick.indexOf(selectedId) !== -1) {
resolve(selectedId);
isResolved = true;
}
} else {
resolve(selectedId);
isResolved = true;
}
}
dialogHelper.close(dlg);
}
});
dlg.addEventListener('close', function () {
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.actionSheetScroller'), false, false);
}
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
if (!isResolved) {
if (selectedId != null) {
if (options.callback) {
options.callback(selectedId);
}
resolve(selectedId);
} else {
reject();
}
}
});
dialogHelper.open(dlg);
const pos = options.positionTo && dialogOptions.size !== 'fullscreen' ? getPosition(options, dlg) : null;
if (pos) {
dlg.style.position = 'fixed';
dlg.style.margin = 0;
dlg.style.left = pos.left + 'px';
dlg.style.top = pos.top + 'px';
}
});
}
export default {
show: show
};

View File

@ -1,360 +0,0 @@
define(['dialogHelper', 'layoutManager', 'globalize', 'browser', 'dom', 'emby-button', 'css!./actionsheet', 'material-icons', 'scrollStyles', 'listViewStyle'], function (dialogHelper, layoutManager, globalize, browser, dom) {
'use strict';
function getOffsets(elems) {
var doc = document;
var results = [];
if (!doc) {
return results;
}
var box;
var elem;
for (var i = 0, length = elems.length; i < length; i++) {
elem = elems[i];
// Support: BlackBerry 5, iOS 3 (original iPhone)
// If we don't have gBCR, just use 0,0 rather than error
if (elem.getBoundingClientRect) {
box = elem.getBoundingClientRect();
} else {
box = { top: 0, left: 0 };
}
results[i] = {
top: box.top,
left: box.left,
width: box.width,
height: box.height
};
}
return results;
}
function getPosition(options, dlg) {
var windowSize = dom.getWindowSize();
var windowHeight = windowSize.innerHeight;
var windowWidth = windowSize.innerWidth;
var pos = getOffsets([options.positionTo])[0];
if (options.positionY !== 'top') {
pos.top += (pos.height || 0) / 2;
}
pos.left += (pos.width || 0) / 2;
var height = dlg.offsetHeight || 300;
var width = dlg.offsetWidth || 160;
// Account for popup size
pos.top -= height / 2;
pos.left -= width / 2;
// Avoid showing too close to the bottom
var overflowX = pos.left + width - windowWidth;
var overflowY = pos.top + height - windowHeight;
if (overflowX > 0) {
pos.left -= (overflowX + 20);
}
if (overflowY > 0) {
pos.top -= (overflowY + 20);
}
pos.top += (options.offsetTop || 0);
pos.left += (options.offsetLeft || 0);
// Do some boundary checking
pos.top = Math.max(pos.top, 10);
pos.left = Math.max(pos.left, 10);
return pos;
}
function centerFocus(elem, horiz, on) {
require(['scrollHelper'], function (scrollHelper) {
var fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
});
}
function show(options) {
// items
// positionTo
// showCancel
// title
var dialogOptions = {
removeOnClose: true,
enableHistory: options.enableHistory,
scrollY: false
};
var backButton = false;
var isFullscreen;
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
isFullscreen = true;
backButton = true;
dialogOptions.autoFocus = true;
} else {
dialogOptions.modal = false;
dialogOptions.entryAnimation = options.entryAnimation;
dialogOptions.exitAnimation = options.exitAnimation;
dialogOptions.entryAnimationDuration = options.entryAnimationDuration || 140;
dialogOptions.exitAnimationDuration = options.exitAnimationDuration || 100;
dialogOptions.autoFocus = false;
}
var dlg = dialogHelper.createDialog(dialogOptions);
if (isFullscreen) {
dlg.classList.add('actionsheet-fullscreen');
} else {
dlg.classList.add('actionsheet-not-fullscreen');
}
dlg.classList.add('actionSheet');
if (options.dialogClass) {
dlg.classList.add(options.dialogClass);
}
var html = '';
var scrollClassName = layoutManager.tv ? 'scrollY smoothScrollY hiddenScrollY' : 'scrollY';
var style = '';
// Admittedly a hack but right now the scrollbar is being factored into the width which is causing truncation
if (options.items.length > 20) {
var minWidth = dom.getWindowSize().innerWidth >= 300 ? 240 : 200;
style += 'min-width:' + minWidth + 'px;';
}
var i;
var length;
var option;
var renderIcon = false;
var icons = [];
var itemIcon;
for (i = 0, length = options.items.length; i < length; i++) {
option = options.items[i];
itemIcon = option.icon || (option.selected ? 'check' : null);
if (itemIcon) {
renderIcon = true;
}
icons.push(itemIcon || '');
}
if (layoutManager.tv) {
html += '<button is="paper-icon-button-light" class="btnCloseActionSheet hide-mouse-idle-tv" tabindex="-1"><span class="material-icons arrow_back"></span></button>';
}
// If any items have an icon, give them all an icon just to make sure they're all lined up evenly
var center = options.title && (!renderIcon /*|| itemsWithIcons.length != options.items.length*/);
if (center || layoutManager.tv) {
html += '<div class="actionSheetContent actionSheetContent-centered">';
} else {
html += '<div class="actionSheetContent">';
}
if (options.title) {
html += '<h1 class="actionSheetTitle">';
html += options.title;
html += '</h1>';
}
if (options.text) {
html += '<p class="actionSheetText">';
html += options.text;
html += '</p>';
}
var scrollerClassName = 'actionSheetScroller';
if (layoutManager.tv) {
scrollerClassName += ' actionSheetScroller-tv focuscontainer-x focuscontainer-y';
}
html += '<div class="' + scrollerClassName + ' ' + scrollClassName + '" style="' + style + '">';
var menuItemClass = 'listItem listItem-button actionSheetMenuItem';
if (options.border || options.shaded) {
menuItemClass += ' listItem-border';
}
if (options.menuItemClass) {
menuItemClass += ' ' + options.menuItemClass;
}
if (layoutManager.tv) {
menuItemClass += ' listItem-focusscale';
}
if (layoutManager.mobile) {
menuItemClass += ' actionsheet-xlargeFont';
}
for (i = 0, length = options.items.length; i < length; i++) {
option = options.items[i];
if (option.divider) {
html += '<div class="actionsheetDivider"></div>';
continue;
}
var autoFocus = option.selected && layoutManager.tv ? ' autoFocus' : '';
// Check for null in case int 0 was passed in
var optionId = option.id == null || option.id === '' ? option.value : option.id;
html += '<button' + autoFocus + ' is="emby-button" type="button" class="' + menuItemClass + '" data-id="' + optionId + '">';
itemIcon = icons[i];
if (itemIcon) {
html += '<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons ' + itemIcon + '"></span>';
} else if (renderIcon && !center) {
html += '<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" style="visibility:hidden;"></span>';
}
html += '<div class="listItemBody actionsheetListItemBody">';
html += '<div class="listItemBodyText actionSheetItemText">';
html += (option.name || option.textContent || option.innerText);
html += '</div>';
if (option.secondaryText) {
html += '<div class="listItemBodyText secondary">';
html += option.secondaryText;
html += '</div>';
}
html += '</div>';
if (option.asideText) {
html += '<div class="listItemAside actionSheetItemAsideText">';
html += option.asideText;
html += '</div>';
}
html += '</button>';
}
if (options.showCancel) {
html += '<div class="buttons">';
html += '<button is="emby-button" type="button" class="btnCloseActionSheet">' + globalize.translate('ButtonCancel') + '</button>';
html += '</div>';
}
html += '</div>';
dlg.innerHTML = html;
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.actionSheetScroller'), false, true);
}
var btnCloseActionSheet = dlg.querySelector('.btnCloseActionSheet');
if (btnCloseActionSheet) {
dlg.querySelector('.btnCloseActionSheet').addEventListener('click', function () {
dialogHelper.close(dlg);
});
}
// Seeing an issue in some non-chrome browsers where this is requiring a double click
//var eventName = browser.firefox ? 'mousedown' : 'click';
var selectedId;
var timeout;
if (options.timeout) {
timeout = setTimeout(function () {
dialogHelper.close(dlg);
}, options.timeout);
}
return new Promise(function (resolve, reject) {
var isResolved;
dlg.addEventListener('click', function (e) {
var actionSheetMenuItem = dom.parentWithClass(e.target, 'actionSheetMenuItem');
if (actionSheetMenuItem) {
selectedId = actionSheetMenuItem.getAttribute('data-id');
if (options.resolveOnClick) {
if (options.resolveOnClick.indexOf) {
if (options.resolveOnClick.indexOf(selectedId) !== -1) {
resolve(selectedId);
isResolved = true;
}
} else {
resolve(selectedId);
isResolved = true;
}
}
dialogHelper.close(dlg);
}
});
dlg.addEventListener('close', function () {
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.actionSheetScroller'), false, false);
}
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
if (!isResolved) {
if (selectedId != null) {
if (options.callback) {
options.callback(selectedId);
}
resolve(selectedId);
} else {
reject();
}
}
});
dialogHelper.open(dlg);
var pos = options.positionTo && dialogOptions.size !== 'fullscreen' ? getPosition(options, dlg) : null;
if (pos) {
dlg.style.position = 'fixed';
dlg.style.margin = 0;
dlg.style.left = pos.left + 'px';
dlg.style.top = pos.top + 'px';
}
});
}
return {
show: show
};
});

View File

@ -34,10 +34,14 @@ define(['events', 'globalize', 'dom', 'date-fns', 'dfnshelper', 'userSettings',
html += '</div>';
if (entry.Overview) {
html += '<button type="button" is="paper-icon-button-light" class="btnEntryInfo" data-id="' + entry.Id + '" title="' + globalize.translate('Info') + '"><span class="material-icons info"></span></button>';
html += `<button type="button" is="paper-icon-button-light" class="btnEntryInfo" data-id="${entry.Id}" title="${globalize.translate('Info')}">
<span class="material-icons info"></span>
</button>`;
}
return html += '</div>';
html += '</div>';
return html;
}
function renderList(elem, apiClient, result, startIndex, limit) {

View File

@ -1,4 +1,4 @@
define(['browser', 'css!./appfooter'], function (browser) {
define(['browser', 'css!./appFooter'], function (browser) {
'use strict';
function render(options) {

View File

@ -1,4 +1,4 @@
define(['loading', 'globalize', 'events', 'viewManager', 'layoutManager', 'skinManager', 'pluginManager', 'backdrop', 'browser', 'page', 'appSettings', 'apphost', 'connectionManager'], function (loading, globalize, events, viewManager, layoutManager, skinManager, pluginManager, backdrop, browser, page, appSettings, appHost, connectionManager) {
define(['loading', 'globalize', 'events', 'viewManager', 'skinManager', 'backdrop', 'browser', 'page', 'appSettings', 'apphost', 'connectionManager'], function (loading, globalize, events, viewManager, skinManager, backdrop, browser, page, appSettings, appHost, connectionManager) {
'use strict';
var appRouter = {
@ -26,11 +26,11 @@ define(['loading', 'globalize', 'events', 'viewManager', 'layoutManager', 'skinM
connectionManager.connect({
enableAutoLogin: appSettings.enableAutoLogin()
}).then(function (result) {
handleConnectionResult(result, loading);
handleConnectionResult(result);
});
}
function handleConnectionResult(result, loading) {
function handleConnectionResult(result) {
switch (result.State) {
case 'SignedIn':
loading.hide();
@ -246,13 +246,11 @@ define(['loading', 'globalize', 'events', 'viewManager', 'layoutManager', 'skinM
}
if (setQuality) {
var quality = 100;
var quality;
var type = options.type || 'Primary';
if (browser.tv || browser.slow) {
// TODO: wtf
if (browser.chrome) {
// webp support
quality = type === 'Primary' ? 40 : 50;
@ -384,7 +382,7 @@ define(['loading', 'globalize', 'events', 'viewManager', 'layoutManager', 'skinM
if (firstResult.State !== 'SignedIn' && !route.anonymous) {
handleConnectionResult(firstResult, loading);
handleConnectionResult(firstResult);
return;
}
}
@ -463,7 +461,6 @@ define(['loading', 'globalize', 'events', 'viewManager', 'layoutManager', 'skinM
return Promise.resolve();
}
var isHandlingBackToDefault;
var isDummyBackToHome;
function loadContent(ctx, route, html, request) {
@ -589,8 +586,7 @@ define(['loading', 'globalize', 'events', 'viewManager', 'layoutManager', 'skinM
path = '/' + path;
}
var baseRoute = baseUrl();
path = path.replace(baseRoute, '');
path = path.replace(baseUrl(), '');
if (currentRouteInfo && currentRouteInfo.path === path) {
// can't use this with home right now due to the back menu
@ -621,10 +617,11 @@ define(['loading', 'globalize', 'events', 'viewManager', 'layoutManager', 'skinM
}
function showItem(item, serverId, options) {
// TODO: Refactor this so it only gets items, not strings.
if (typeof (item) === 'string') {
var apiClient = serverId ? connectionManager.getApiClient(serverId) : connectionManager.currentApiClient();
apiClient.getItem(apiClient.getCurrentUserId(), item).then(function (item) {
appRouter.showItem(item, options);
apiClient.getItem(apiClient.getCurrentUserId(), item).then(function (itemObject) {
appRouter.showItem(itemObject, options);
});
} else {
if (arguments.length === 2) {

View File

@ -5,7 +5,7 @@ define(['appSettings', 'browser', 'events', 'htmlMediaHelper', 'webSettings', 'g
var disableHlsVideoAudioCodecs = [];
if (item && htmlMediaHelper.enableHlsJsPlayer(item.RunTimeTicks, item.MediaType)) {
if (browser.edge || browser.msie) {
if (browser.edge) {
disableHlsVideoAudioCodecs.push('mp3');
}
@ -93,18 +93,36 @@ define(['appSettings', 'browser', 'events', 'htmlMediaHelper', 'webSettings', 'g
function getDeviceName() {
var deviceName;
deviceName = browser.tizen ? 'Samsung Smart TV' : browser.web0s ? 'LG Smart TV' : browser.operaTv ? 'Opera TV' : browser.xboxOne ? 'Xbox One' : browser.ps4 ? 'Sony PS4' : browser.chrome ? 'Chrome' : browser.edge ? 'Edge' : browser.firefox ? 'Firefox' : browser.msie ? 'Internet Explorer' : browser.opera ? 'Opera' : browser.safari ? 'Safari' : 'Web Browser';
if (browser.tizen) {
deviceName = 'Samsung Smart TV';
} else if (browser.web0s) {
deviceName = 'LG Smart TV';
} else if (browser.operaTv) {
deviceName = 'Opera TV';
} else if (browser.xboxOne) {
deviceName = 'Xbox One';
} else if (browser.ps4) {
deviceName = 'Sony PS4';
} else if (browser.chrome) {
deviceName = 'Chrome';
} else if (browser.edge) {
deviceName = 'Edge';
} else if (browser.firefox) {
deviceName = 'Firefox';
} else if (browser.opera) {
deviceName = 'Opera';
} else if (browser.safari) {
deviceName = 'Safari';
} else {
deviceName = 'Web Browser';
}
if (browser.ipad) {
deviceName += ' iPad';
} else {
if (browser.iphone) {
deviceName += ' iPhone';
} else {
if (browser.android) {
deviceName += ' Android';
}
}
} else if (browser.iphone) {
deviceName += ' iPhone';
} else if (browser.android) {
deviceName += ' Android';
}
return deviceName;
@ -267,7 +285,7 @@ define(['appSettings', 'browser', 'events', 'htmlMediaHelper', 'webSettings', 'g
if (enabled) features.push('multiserver');
});
if (!browser.orsay && !browser.msie && (browser.firefox || browser.ps4 || browser.edge || supportsCue())) {
if (!browser.orsay && (browser.firefox || browser.ps4 || browser.edge || supportsCue())) {
features.push('subtitleappearancesettings');
}
@ -359,7 +377,6 @@ define(['appSettings', 'browser', 'events', 'htmlMediaHelper', 'webSettings', 'g
return -1 !== supportedFeatures.indexOf(command.toLowerCase());
},
preferVisualCards: browser.android || browser.chrome,
moreIcon: browser.android ? 'more_vert' : 'more_horiz',
getSyncProfile: getSyncProfile,
getDefaultLayout: function () {
if (window.NativeShell) {

View File

@ -0,0 +1,278 @@
import connectionManager from 'connectionManager';
import loading from 'loading';
import keyboardnavigation from 'keyboardnavigation';
import dialogHelper from 'dialogHelper';
import events from 'events';
import 'css!./style';
import 'material-icons';
import 'paper-icon-button-light';
import TableOfContent from './tableOfContent';
export class BookPlayer {
constructor() {
this.name = 'Book Player';
this.type = 'mediaplayer';
this.id = 'bookplayer';
this.priority = 1;
this.onDialogClosed = this.onDialogClosed.bind(this);
this.openTableOfContents = this.openTableOfContents.bind(this);
this.onWindowKeyUp = this.onWindowKeyUp.bind(this);
}
play(options) {
this._progress = 0;
this._loaded = false;
loading.show();
let elem = this.createMediaElement();
return this.setCurrentSrc(elem, options);
}
stop() {
this.unbindEvents();
let elem = this._mediaElement;
let tocElement = this._tocElement;
let rendition = this._rendition;
if (elem) {
dialogHelper.close(elem);
this._mediaElement = null;
}
if (tocElement) {
tocElement.destroy();
this._tocElement = null;
}
if (rendition) {
rendition.destroy();
}
// Hide loader in case player was not fully loaded yet
loading.hide();
this._cancellationToken.shouldCancel = true;
}
currentItem() {
return this._currentItem;
}
currentTime() {
return this._progress * 1000;
}
duration() {
return 1000;
}
getBufferedRanges() {
return [{
start: 0,
end: 10000000
}];
}
volume() {
return 100;
}
isMuted() {
return false;
}
paused() {
return false;
}
seekable() {
return true;
}
onWindowKeyUp(e) {
let key = keyboardnavigation.getKeyName(e);
let rendition = this._rendition;
let book = rendition.book;
switch (key) {
case 'l':
case 'ArrowRight':
case 'Right':
if (this._loaded) {
book.package.metadata.direction === 'rtl' ? rendition.prev() : rendition.next();
}
break;
case 'j':
case 'ArrowLeft':
case 'Left':
if (this._loaded) {
book.package.metadata.direction === 'rtl' ? rendition.next() : rendition.prev();
}
break;
case 'Escape':
if (this._tocElement) {
// Close table of contents on ESC if it is open
this._tocElement.destroy();
} else {
// Otherwise stop the entire book player
this.stop();
}
break;
}
}
onDialogClosed() {
this.stop();
}
bindMediaElementEvents() {
let elem = this._mediaElement;
elem.addEventListener('close', this.onDialogClosed, {once: true});
elem.querySelector('.btnBookplayerExit').addEventListener('click', this.onDialogClosed, {once: true});
elem.querySelector('.btnBookplayerToc').addEventListener('click', this.openTableOfContents);
}
bindEvents() {
this.bindMediaElementEvents();
document.addEventListener('keyup', this.onWindowKeyUp);
// FIXME: I don't really get why document keyup event is not triggered when epub is in focus
this._rendition.on('keyup', this.onWindowKeyUp);
}
unbindMediaElementEvents() {
let elem = this._mediaElement;
elem.removeEventListener('close', this.onDialogClosed);
elem.querySelector('.btnBookplayerExit').removeEventListener('click', this.onDialogClosed);
elem.querySelector('.btnBookplayerToc').removeEventListener('click', this.openTableOfContents);
}
unbindEvents() {
if (this._mediaElement) {
this.unbindMediaElementEvents();
}
document.removeEventListener('keyup', this.onWindowKeyUp);
if (this._rendition) {
this._rendition.off('keyup', this.onWindowKeyUp);
}
}
openTableOfContents() {
if (this._loaded) {
this._tocElement = new TableOfContent(this);
}
}
createMediaElement() {
let elem = this._mediaElement;
if (elem) {
return elem;
}
elem = document.getElementById('bookPlayer');
if (!elem) {
elem = dialogHelper.createDialog({
exitAnimationDuration: 400,
size: 'fullscreen',
autoFocus: false,
scrollY: false,
exitAnimation: 'fadeout',
removeOnClose: true
});
elem.id = 'bookPlayer';
let html = '';
html += '<div class="topRightActionButtons">';
html += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerExit hide-mouse-idle-tv" tabindex="-1"><i class="material-icons bookplayerButtonIcon close"></i></button>';
html += '</div>';
html += '<div class="topLeftActionButtons">';
html += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerToc hide-mouse-idle-tv" tabindex="-1"><i class="material-icons bookplayerButtonIcon toc"></i></button>';
html += '</div>';
elem.innerHTML = html;
dialogHelper.open(elem);
}
this._mediaElement = elem;
return elem;
}
setCurrentSrc(elem, options) {
let item = options.items[0];
this._currentItem = item;
this.streamInfo = {
started: true,
ended: false,
mediaSource: {
Id: item.Id
}
};
let serverId = item.ServerId;
let apiClient = connectionManager.getApiClient(serverId);
return new Promise((resolve, reject) => {
require(['epubjs'], (epubjs) => {
let downloadHref = apiClient.getItemDownloadUrl(item.Id);
let book = epubjs.default(downloadHref, {openAs: 'epub'});
let rendition = book.renderTo(elem, {width: '100%', height: '97%'});
this._currentSrc = downloadHref;
this._rendition = rendition;
let cancellationToken = {
shouldCancel: false
};
this._cancellationToken = cancellationToken;
return rendition.display().then(() => {
let epubElem = document.querySelector('.epub-container');
epubElem.style.display = 'none';
this.bindEvents();
return this._rendition.book.locations.generate(1024).then(() => {
if (cancellationToken.shouldCancel) {
return reject();
}
this._loaded = true;
epubElem.style.display = 'block';
rendition.on('relocated', (locations) => {
this._progress = book.locations.percentageFromCfi(locations.start.cfi);
events.trigger(this, 'timeupdate');
});
loading.hide();
return resolve();
});
}, () => {
console.error('Failed to display epub');
return reject();
});
});
});
}
canPlayMediaType(mediaType) {
return (mediaType || '').toLowerCase() === 'book';
}
canPlayItem(item) {
if (item.Path && (item.Path.endsWith('epub'))) {
return true;
}
return false;
}
}
export default BookPlayer;

View File

@ -0,0 +1,39 @@
#bookPlayer {
position: relative;
height: 100%;
width: 100%;
overflow: auto;
z-index: 100;
background: #fff;
}
.topRightActionButtons {
right: 0.5vh;
top: 0.5vh;
z-index: 1002;
position: absolute;
}
.topLeftActionButtons {
left: 0.5vh;
top: 0.5vh;
z-index: 1002;
position: absolute;
}
.bookplayerButtonIcon {
color: black;
opacity: 0.7;
}
#dialogToc {
background-color: white;
}
.toc li {
margin-bottom: 5px;
}
.bookplayerErrorMsg {
text-align: center;
}

View File

@ -0,0 +1,90 @@
import dialogHelper from 'dialogHelper';
export default class TableOfContent {
constructor(bookPlayer) {
this._bookPlayer = bookPlayer;
this._rendition = bookPlayer._rendition;
this.onDialogClosed = this.onDialogClosed.bind(this);
this.createMediaElement();
}
destroy() {
let elem = this._elem;
if (elem) {
this.unbindEvents();
dialogHelper.close(elem);
}
this._bookPlayer._tocElement = null;
}
bindEvents() {
let elem = this._elem;
elem.addEventListener('close', this.onDialogClosed, {once: true});
elem.querySelector('.btnBookplayerTocClose').addEventListener('click', this.onDialogClosed, {once: true});
}
unbindEvents() {
let elem = this._elem;
elem.removeEventListener('close', this.onDialogClosed);
elem.querySelector('.btnBookplayerTocClose').removeEventListener('click', this.onDialogClosed);
}
onDialogClosed() {
this.destroy();
}
replaceLinks(contents, f) {
let links = contents.querySelectorAll('a[href]');
links.forEach((link) => {
let href = link.getAttribute('href');
link.onclick = () => {
f(href);
return false;
};
});
}
createMediaElement() {
let rendition = this._rendition;
let elem = dialogHelper.createDialog({
size: 'small',
autoFocus: false,
removeOnClose: true
});
elem.id = 'dialogToc';
let tocHtml = '<div class="topRightActionButtons">';
tocHtml += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerTocClose hide-mouse-idle-tv" tabindex="-1"><span class="material-icons bookplayerButtonIcon close"></span></button>';
tocHtml += '</div>';
tocHtml += '<ul class="toc">';
rendition.book.navigation.forEach((chapter) => {
tocHtml += '<li>';
// Remove '../' from href
let link = chapter.href.startsWith('../') ? chapter.href.substr(3) : chapter.href;
tocHtml += `<a href="${rendition.book.path.directory + link}">${chapter.label}</a>`;
tocHtml += '</li>';
});
tocHtml += '</ul>';
elem.innerHTML = tocHtml;
this.replaceLinks(elem, (href) => {
let relative = rendition.book.path.relative(href);
rendition.display(relative);
this.destroy();
});
this._elem = elem;
this.bindEvents();
dialogHelper.open(elem);
}
}

View File

@ -306,6 +306,10 @@ button::-moz-focus-inner {
text-align: left;
}
.dialog .cardText {
text-overflow: initial;
}
.cardText-secondary {
font-size: 86%;
}

View File

@ -869,7 +869,7 @@ import 'programStyles';
if (isOuterFooter && options.cardLayout && layoutManager.mobile) {
if (options.cardFooterAside !== 'none') {
html += '<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="menu"><span class="material-icons more_horiz"></span></button>';
html += '<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="menu"><span class="material-icons more_vert"></span></button>';
}
}
@ -1426,7 +1426,7 @@ import 'programStyles';
}
if (options.overlayMoreButton) {
overlayButtons += '<button is="paper-icon-button-light" class="' + btnCssClass + '" data-action="menu"><span class="material-icons cardOverlayButtonIcon more_horiz"></span></button>';
overlayButtons += '<button is="paper-icon-button-light" class="' + btnCssClass + '" data-action="menu"><span class="material-icons cardOverlayButtonIcon more_vert"></span></button>';
}
}
@ -1580,7 +1580,7 @@ import 'programStyles';
html += '<button is="emby-ratingbutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-likes="' + likes + '" data-isfavorite="' + (userData.IsFavorite) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite"></span></button>';
}
html += '<button is="paper-icon-button-light" class="' + btnCssClass + '" data-action="menu"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_horiz"></span></button>';
html += '<button is="paper-icon-button-light" class="' + btnCssClass + '" data-action="menu"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert"></span></button>';
html += '</div>';
html += '</div>';

View File

@ -79,7 +79,7 @@ define(['dom', 'dialogHelper', 'loading', 'connectionManager', 'globalize', 'act
function getEditorHtml() {
var html = '';
html += '<div class="formDialogContent">';
html += '<div class="formDialogContent smoothScrollY">';
html += '<div class="dialogContentInner dialog-content-centered">';
html += '<form style="margin:auto;">';
html += '<h1>' + globalize.translate('HeaderChannels') + '</h1>';

View File

@ -54,7 +54,13 @@ define(['appSettings', 'userSettings', 'playbackManager', 'connectionManager', '
// production version registered with google
// replace this value if you want to test changes on another instance
var applicationID = 'F007D354';
var applicationStable = 'F007D354';
var applicationNightly = '6F511C87';
var applicationID = applicationStable;
if (userSettings.chromecastVersion === 'nightly') {
applicationID = applicationNightly;
}
var messageNamespace = 'urn:x-cast:com.connectsdk';

View File

@ -1,57 +0,0 @@
define(['connectionManager', 'confirm', 'appRouter', 'globalize'], function (connectionManager, confirm, appRouter, globalize) {
'use strict';
function alertText(options) {
return new Promise(function (resolve, reject) {
require(['alert'], function (alert) {
alert(options).then(resolve, resolve);
});
});
}
function deleteItem(options) {
var item = options.item;
var itemId = item.Id;
var parentId = item.SeasonId || item.SeriesId || item.ParentId;
var serverId = item.ServerId;
var msg = globalize.translate('ConfirmDeleteItem');
var title = globalize.translate('HeaderDeleteItem');
var apiClient = connectionManager.getApiClient(item.ServerId);
return confirm({
title: title,
text: msg,
confirmText: globalize.translate('Delete'),
primary: 'delete'
}).then(function () {
return apiClient.deleteItem(itemId).then(function () {
if (options.navigate) {
if (parentId) {
appRouter.showItem(parentId, serverId);
} else {
appRouter.goHome();
}
}
}, function (err) {
var result = function () {
return Promise.reject(err);
};
return alertText(globalize.translate('ErrorDeletingItem')).then(result, result);
});
});
}
return {
deleteItem: deleteItem
};
});

View File

@ -126,25 +126,10 @@
}
@media all and (min-width: 80em) and (min-height: 45em) {
.dialog-medium {
width: 80%;
height: 80%;
}
.dialog-medium-tall {
width: 80%;
height: 90%;
}
.dialog-small {
width: 60%;
height: 80%;
}
.dialog-fullscreen-border {
width: 90%;
height: 90%;
}
}
.noScroll {

View File

@ -89,7 +89,6 @@ define(['loading', 'dialogHelper', 'dom', 'globalize', 'listViewStyle', 'emby-in
var instruction = options.instruction ? options.instruction + '<br/><br/>' : '';
html += '<div class="infoBanner" style="margin-bottom:1.5em;">';
html += instruction;
html += globalize.translate('MessageDirectoryPickerInstruction', '<b>\\\\server</b>', '<b>\\\\192.168.1.101</b>');
if ('bsd' === systemInfo.OperatingSystem.toLowerCase()) {
html += '<br/>';
html += '<br/>';
@ -126,7 +125,7 @@ define(['loading', 'dialogHelper', 'dom', 'globalize', 'listViewStyle', 'emby-in
html += '<div class="inputContainer" style="margin-top:2em;">';
html += '<input is="emby-input" id="txtNetworkPath" type="text" label="' + globalize.translate('LabelOptionalNetworkPath') + '"/>';
html += '<div class="fieldDescription">';
html += globalize.translate('LabelOptionalNetworkPathHelp');
html += globalize.translate('LabelOptionalNetworkPathHelp', '<b>\\\\server</b>', '<b>\\\\192.168.1.101</b>');
html += '</div>';
html += '</div>';
}
@ -253,7 +252,7 @@ define(['loading', 'dialogHelper', 'dom', 'globalize', 'listViewStyle', 'emby-in
var systemInfo = responses[0];
var initialPath = responses[1];
var dlg = dialogHelper.createDialog({
size: 'medium-tall',
size: 'small',
removeOnClose: true,
scrollY: false
});

View File

@ -182,6 +182,7 @@ define(['require', 'browser', 'layoutManager', 'appSettings', 'pluginManager', '
context.querySelector('#chkThemeVideo').checked = userSettings.enableThemeVideos();
context.querySelector('#chkFadein').checked = userSettings.enableFastFadein();
context.querySelector('#chkBackdrops').checked = userSettings.enableBackdrops();
context.querySelector('#chkDetailsBanner').checked = userSettings.detailsBanner();
context.querySelector('#selectLanguage').value = userSettings.language() || '';
context.querySelector('.selectDateTimeLocale').value = userSettings.dateTimeLocale() || '';
@ -223,6 +224,7 @@ define(['require', 'browser', 'layoutManager', 'appSettings', 'pluginManager', '
userSettingsInstance.enableFastFadein(context.querySelector('#chkFadein').checked);
userSettingsInstance.enableBackdrops(context.querySelector('#chkBackdrops').checked);
userSettingsInstance.detailsBanner(context.querySelector('#chkDetailsBanner').checked);
if (user.Id === apiClient.getCurrentUserId()) {
skinManager.setTheme(userSettingsInstance.theme());
@ -269,7 +271,7 @@ define(['require', 'browser', 'layoutManager', 'appSettings', 'pluginManager', '
}
function embed(options, self) {
require(['text!./displaysettings.template.html'], function (template) {
require(['text!./displaySettings.template.html'], function (template) {
options.element.innerHTML = globalize.translateDocument(template, 'core');
options.element.querySelector('form').addEventListener('submit', onSubmit.bind(self));
if (options.enableSaveButton) {

View File

@ -156,6 +156,14 @@
<div class="fieldDescription checkboxFieldDescription">${EnableFastImageFadeInHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldDetailsBanner">
<label>
<input type="checkbox" is="emby-checkbox" id="chkDetailsBanner" />
<span>${EnableDetailsBanner}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${EnableDetailsBannerHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldBackdrops hide">
<label>
<input type="checkbox" is="emby-checkbox" id="chkBackdrops" />

View File

@ -19,6 +19,10 @@
margin-bottom: 0;
}
.formDialogHeaderTitle:first-child {
margin-left: 1em;
}
.formDialogContent:not(.no-grow) {
flex-grow: 1;
}
@ -46,10 +50,16 @@
right: 0;
display: flex;
position: absolute;
padding: 1.25em 1em;
padding: 1em 1em;
/* Without this emby-checkbox is able to appear on top */
z-index: 1;
align-items: flex-end;
justify-content: flex-end;
flex-wrap: wrap;
}
.layout-tv .formDialogFooter {
align-items: center;
justify-content: center;
flex-wrap: wrap;
@ -69,8 +79,12 @@
.formDialogFooterItem {
margin: 0.5em !important;
flex-grow: 1;
text-align: center;
flex-basis: 12em;
}
.layout-tv .formDialogFooterItem {
flex-grow: 1;
flex-basis: 0;
}

View File

@ -10,7 +10,7 @@
<div class="guide-headerTimeslots">
<div class="guide-channelTimeslotHeader">
<button is="paper-icon-button-light" type="button" class="btnGuideViewSettings" title="${ButtonMore}">
<span class="material-icons btnGuideViewSettingsIcon more_horiz" aria-hidden="true"></span>
<span class="material-icons btnGuideViewSettingsIcon more_vert" aria-hidden="true"></span>
</button>
</div>
<div class="timeslotHeaders scrollX guideScroller"></div>

View File

@ -37,18 +37,19 @@ define(['require', 'apphost', 'layoutManager', 'focusManager', 'globalize', 'loa
var list = [];
if (type === 'movies') {
list.push({
name: globalize.translate('Movies'),
value: 'movies',
isDefault: true
});
list.push({
name: globalize.translate('Suggestions'),
value: 'suggestions'
});
list.push({
name: globalize.translate('Genres'),
value: 'genres'
});
list.push({
name: globalize.translate('Favorites'),
value: 'favorites'
@ -58,7 +59,6 @@ define(['require', 'apphost', 'layoutManager', 'focusManager', 'globalize', 'loa
value: 'collections'
});
} else if (type === 'tvshows') {
list.push({
name: globalize.translate('Shows'),
value: 'shows',
@ -68,49 +68,45 @@ define(['require', 'apphost', 'layoutManager', 'focusManager', 'globalize', 'loa
name: globalize.translate('Suggestions'),
value: 'suggestions'
});
list.push({
name: globalize.translate('Latest'),
value: 'latest'
});
list.push({
name: globalize.translate('Genres'),
value: 'genres'
});
list.push({
name: globalize.translate('Favorites'),
value: 'favorites'
});
} else if (type === 'music') {
list.push({
name: globalize.translate('Suggestions'),
value: 'suggestions',
isDefault: true
});
list.push({
name: globalize.translate('Albums'),
value: 'albums'
});
list.push({
name: globalize.translate('HeaderAlbumArtists'),
value: 'albumartists'
});
list.push({
name: globalize.translate('Artists'),
value: 'artists'
});
list.push({
name: globalize.translate('Playlists'),
value: 'playlists'
});
list.push({
name: globalize.translate('Genres'),
value: 'genres'
});
} else if (type === 'livetv') {
list.push({
name: globalize.translate('Suggestions'),
value: 'suggestions',
@ -470,7 +466,7 @@ define(['require', 'apphost', 'layoutManager', 'focusManager', 'globalize', 'loa
function embed(options, self) {
require(['text!./homescreensettings.template.html'], function (template) {
require(['text!./homeScreenSettings.template.html'], function (template) {
for (var i = 1; i <= numConfigurableSections; i++) {
template = template.replace('{section' + i + 'label}', globalize.translate('LabelHomeScreenSectionValue', i));

View File

@ -229,7 +229,7 @@ define(['connectionManager', 'cardBuilder', 'appSettings', 'dom', 'apphost', 'la
var options = {
Limit: limit,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo',
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo,Path',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
ParentId: parentId
@ -667,7 +667,7 @@ define(['connectionManager', 'cardBuilder', 'appSettings', 'dom', 'apphost', 'la
var apiClient = connectionManager.getApiClient(serverId);
return apiClient.getNextUpEpisodes({
Limit: enableScrollX() ? 24 : 15,
Fields: 'PrimaryImageAspectRatio,SeriesInfo,DateCreated,BasicSyncInfo',
Fields: 'PrimaryImageAspectRatio,SeriesInfo,DateCreated,BasicSyncInfo,Path',
UserId: apiClient.getCurrentUserId(),
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',

View File

@ -177,6 +177,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
elem.addEventListener('pause', onPause);
elem.addEventListener('playing', onPlaying);
elem.addEventListener('play', onPlay);
elem.addEventListener('waiting', onWaiting);
}
function unBindEvents(elem) {
@ -186,6 +187,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
elem.removeEventListener('pause', onPause);
elem.removeEventListener('playing', onPlaying);
elem.removeEventListener('play', onPlay);
elem.removeEventListener('waiting', onWaiting);
}
self.stop = function (destroyPlayer) {
@ -300,6 +302,10 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
events.trigger(self, 'pause');
}
function onWaiting() {
events.trigger(self, 'waiting');
}
function onError() {
var errorCode = this.error ? (this.error.code || 0) : 0;
@ -456,6 +462,21 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
return false;
};
HtmlAudioPlayer.prototype.setPlaybackRate = function (value) {
var mediaElement = this._mediaElement;
if (mediaElement) {
mediaElement.playbackRate = value;
}
};
HtmlAudioPlayer.prototype.getPlaybackRate = function () {
var mediaElement = this._mediaElement;
if (mediaElement) {
return mediaElement.playbackRate;
}
return null;
};
HtmlAudioPlayer.prototype.setVolume = function (val) {
var mediaElement = this._mediaElement;
if (mediaElement) {
@ -499,5 +520,26 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
};
var supportedFeatures;
function getSupportedFeatures() {
var list = [];
var audio = document.createElement('audio');
if (typeof audio.playbackRate === 'number') {
list.push('PlaybackRate');
}
return list;
}
HtmlAudioPlayer.prototype.supports = function (feature) {
if (!supportedFeatures) {
supportedFeatures = getSupportedFeatures();
}
return supportedFeatures.indexOf(feature) !== -1;
};
return HtmlAudioPlayer;
});

View File

@ -109,6 +109,8 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
function hidePrePlaybackPage() {
let animatedPage = document.querySelector('.page:not(.hide)');
animatedPage.classList.add('hide');
// At this point, we must hide the scrollbar placeholder, so it's not being displayed while the item is being loaded
document.body.classList.remove('force-scroll');
}
function zoomIn(elem) {
@ -279,14 +281,6 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
}
self.play = function (options) {
if (browser.msie) {
if (options.playMethod === 'Transcode' && !window.MediaSource) {
alert('Playback of this content is not supported in Internet Explorer. For a better experience, try a modern browser such as Microsoft Edge, Google Chrome, Firefox or Opera.');
return Promise.reject();
}
}
self._started = false;
self._timeUpdated = false;
@ -797,6 +791,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
videoElement.removeEventListener('play', onPlay);
videoElement.removeEventListener('click', onClick);
videoElement.removeEventListener('dblclick', onDblClick);
videoElement.removeEventListener('waiting', onWaiting);
videoElement.parentNode.removeChild(videoElement);
}
@ -925,6 +920,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
events.trigger(self, 'pause');
}
function onWaiting() {
events.trigger(self, 'waiting');
}
function onError() {
var errorCode = this.error ? (this.error.code || 0) : 0;
var errorMessage = this.error ? (this.error.message || '') : '';
@ -1347,6 +1346,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
videoElement.addEventListener('play', onPlay);
videoElement.addEventListener('click', onClick);
videoElement.addEventListener('dblclick', onDblClick);
videoElement.addEventListener('waiting', onWaiting);
if (options.backdropUrl) {
videoElement.poster = options.backdropUrl;
}
@ -1417,13 +1417,6 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
var video = document.createElement('video');
if (video.webkitSupportsPresentationMode && typeof video.webkitSetPresentationMode === 'function' || document.pictureInPictureEnabled) {
list.push('PictureInPicture');
} else if (browser.ipad) {
// Unfortunately this creates a false positive on devices where its' not actually supported
if (navigator.userAgent.toLowerCase().indexOf('os 9') === -1) {
if (video.webkitSupportsPresentationMode && video.webkitSupportsPresentationMode && typeof video.webkitSetPresentationMode === 'function') {
list.push('PictureInPicture');
}
}
} else if (window.Windows) {
if (Windows.UI.ViewManagement.ApplicationView.getForCurrentView().isViewModeSupported(Windows.UI.ViewManagement.ApplicationViewMode.compactOverlay)) {
list.push('PictureInPicture');
@ -1434,6 +1427,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
list.push('AirPlay');
}
if (typeof video.playbackRate === 'number') {
list.push('PlaybackRate');
}
list.push('SetBrightness');
list.push('SetAspectRatio');
@ -1654,6 +1651,21 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
return false;
};
HtmlVideoPlayer.prototype.setPlaybackRate = function (value) {
var mediaElement = this._mediaElement;
if (mediaElement) {
mediaElement.playbackRate = value;
}
};
HtmlVideoPlayer.prototype.getPlaybackRate = function () {
var mediaElement = this._mediaElement;
if (mediaElement) {
return mediaElement.playbackRate;
}
return null;
};
HtmlVideoPlayer.prototype.setVolume = function (val) {
var mediaElement = this._mediaElement;
if (mediaElement) {

View File

@ -203,9 +203,9 @@ define(['dom', 'loading', 'apphost', 'dialogHelper', 'connectionManager', 'image
html += '<div class="cardContent">';
if (layoutManager.tv || !appHost.supports('externallinks')) {
html += '<div class="cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center bottom;"></div>';
html += '<div class="cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center center;background-size:contain;"></div>';
} else {
html += '<a is="emby-linkbutton" target="_blank" href="' + getDisplayUrl(image.Url, apiClient) + '" class="button-link cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center bottom;"></a>';
html += '<a is="emby-linkbutton" target="_blank" href="' + getDisplayUrl(image.Url, apiClient) + '" class="button-link cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center center;background-size:contain"></a>';
}
html += '</div>';
@ -320,7 +320,7 @@ define(['dom', 'loading', 'apphost', 'dialogHelper', 'connectionManager', 'image
function showEditor(itemId, serverId, itemType) {
loading.show();
require(['text!./imagedownloader.template.html'], function (template) {
require(['text!./imageDownloader.template.html'], function (template) {
var apiClient = connectionManager.getApiClient(serverId);
@ -334,7 +334,7 @@ define(['dom', 'loading', 'apphost', 'dialogHelper', 'connectionManager', 'image
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'fullscreen-border';
dialogOptions.size = 'small';
}
var dlg = dialogHelper.createDialog(dialogOptions);

View File

@ -5,7 +5,7 @@
</h3>
</div>
<div class="formDialogContent">
<div class="formDialogContent smoothScrollY">
<div class="dialogContentInner">
<div class="flex align-items-center justify-content-center flex-wrap-wrap" style="margin: 2em 0;">

View File

@ -82,12 +82,12 @@ define(['globalize', 'dom', 'dialogHelper', 'emby-checkbox', 'emby-select', 'emb
this.show = function (itemType, options, availableOptions) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'components/imageoptionseditor/imageoptionseditor.template.html', true);
xhr.open('GET', 'components/imageOptionsEditor/imageOptionsEditor.template.html', true);
xhr.onload = function (e) {
var template = this.response;
var dlg = dialogHelper.createDialog({
size: 'medium-tall',
size: 'small',
removeOnClose: true,
scrollY: false
});

View File

@ -125,7 +125,7 @@ define(['dialogHelper', 'connectionManager', 'dom', 'loading', 'scrollHelper', '
options = options || {};
require(['text!./imageuploader.template.html'], function (template) {
require(['text!./imageUploader.template.html'], function (template) {
currentItemId = options.itemId;
currentServerId = options.serverId;
@ -137,7 +137,7 @@ define(['dialogHelper', 'connectionManager', 'dom', 'loading', 'scrollHelper', '
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'fullscreen-border';
dialogOptions.size = 'small';
}
var dlg = dialogHelper.createDialog(dialogOptions);

View File

@ -5,7 +5,7 @@
</h3>
</div>
<div class="formDialogContent">
<div class="formDialogContent smoothScrollY">
<div class="dialogContentInner">
<form class="uploadItemImageForm" style="max-width: 100%;">

View File

@ -132,7 +132,7 @@ define(['dialogHelper', 'connectionManager', 'loading', 'dom', 'layoutManager',
var imageUrl = getImageUrl(currentItem, apiClient, image.ImageType, image.ImageIndex, { maxWidth: imageSize });
html += '<div class="cardImageContainer" style="background-image:url(\'' + imageUrl + '\');background-position:center bottom;"></div>';
html += '<div class="cardImageContainer" style="background-image:url(\'' + imageUrl + '\');background-position:center center;background-size:contain;"></div>';
html += '</div>';
html += '</div>';
@ -457,7 +457,7 @@ define(['dialogHelper', 'connectionManager', 'loading', 'dom', 'layoutManager',
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'fullscreen-border';
dialogOptions.size = 'small';
}
var dlg = dialogHelper.createDialog(dialogOptions);

View File

@ -5,7 +5,7 @@
</h3>
</div>
<div class="formDialogContent">
<div class="formDialogContent smoothScrollY">
<div class="dialogContentInner">
<div id="imagesContainer">

View File

@ -1,38 +0,0 @@
define(['dom'], function (dom) {
'use strict';
function loadImage(elem, url) {
if (!elem) {
return Promise.reject('elem cannot be null');
}
if (elem.tagName !== 'IMG') {
elem.style.backgroundImage = "url('" + url + "')";
return Promise.resolve();
//return loadImageIntoImg(document.createElement('img'), url).then(function () {
// elem.style.backgroundImage = "url('" + url + "')";
// return Promise.resolve();
//});
}
return loadImageIntoImg(elem, url);
}
function loadImageIntoImg(elem, url) {
return new Promise(function (resolve, reject) {
dom.addEventListener(elem, 'load', resolve, {
once: true
});
elem.setAttribute('src', url);
});
}
return {
loadImage: loadImage
};
});

View File

@ -1,198 +1,183 @@
define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'material-icons'], function (datetime, itemHelper) {
'use strict';
import datetime from 'datetime';
import itemHelper from 'itemHelper';
import 'emby-progressbar';
import 'css!./indicators.css';
import 'material-icons';
function enableProgressIndicator(item) {
if (item.MediaType === 'Video') {
if (item.Type !== 'TvChannel') {
return true;
}
}
if (item.Type === 'AudioBook' || item.Type === 'AudioPodcast') {
return true;
}
return false;
export function enableProgressIndicator(item) {
if (item.MediaType === 'Video' && item.Type !== 'TvChannel') {
return true;
}
function getProgressHtml(pct, options) {
var containerClass = 'itemProgressBar';
if (options) {
if (options.containerClass) {
containerClass += ' ' + options.containerClass;
}
}
return '<div class="' + containerClass + '"><div class="itemProgressBarForeground" style="width:' + pct + '%;"></div></div>';
if (item.Type === 'AudioBook' || item.Type === 'AudioPodcast') {
return true;
}
function getAutoTimeProgressHtml(pct, options, isRecording, start, end) {
var containerClass = 'itemProgressBar';
if (options) {
if (options.containerClass) {
containerClass += ' ' + options.containerClass;
}
}
return false;
}
var foregroundClass = 'itemProgressBarForeground';
if (isRecording) {
foregroundClass += ' itemProgressBarForeground-recording';
}
return '<div is="emby-progressbar" data-automode="time" data-starttime="' + start + '" data-endtime="' + end + '" class="' + containerClass + '"><div class="' + foregroundClass + '" style="width:' + pct + '%;"></div></div>';
export function getProgressHtml(pct, options) {
let containerClass = 'itemProgressBar';
if (options && options.containerClass) {
containerClass += ' ' + options.containerClass;
}
function getProgressBarHtml(item, options) {
var pct;
if (enableProgressIndicator(item) && item.Type !== 'Recording') {
var userData = options ? (options.userData || item.UserData) : item.UserData;
if (userData) {
pct = userData.PlayedPercentage;
if (pct && pct < 100) {
return getProgressHtml(pct, options);
}
return '<div class="' + containerClass + '"><div class="itemProgressBarForeground" style="width:' + pct + '%;"></div></div>';
}
function getAutoTimeProgressHtml(pct, options, isRecording, start, end) {
let containerClass = 'itemProgressBar';
if (options && options.containerClass) {
containerClass += ' ' + options.containerClass;
}
let foregroundClass = 'itemProgressBarForeground';
if (isRecording) {
foregroundClass += ' itemProgressBarForeground-recording';
}
return '<div is="emby-progressbar" data-automode="time" data-starttime="' + start + '" data-endtime="' + end + '" class="' + containerClass + '"><div class="' + foregroundClass + '" style="width:' + pct + '%;"></div></div>';
}
export function getProgressBarHtml(item, options) {
let pct;
if (enableProgressIndicator(item) && item.Type !== 'Recording') {
const userData = options && options.userData ? options.userData : item.UserData;
if (userData) {
pct = userData.PlayedPercentage;
if (pct && pct < 100) {
return getProgressHtml(pct, options);
}
}
}
if ((item.Type === 'Program' || item.Type === 'Timer' || item.Type === 'Recording') && item.StartDate && item.EndDate) {
var startDate = 0;
var endDate = 1;
if ((item.Type === 'Program' || item.Type === 'Timer' || item.Type === 'Recording') && item.StartDate && item.EndDate) {
let startDate = 0;
let endDate = 1;
try {
startDate = datetime.parseISO8601Date(item.StartDate).getTime();
endDate = datetime.parseISO8601Date(item.EndDate).getTime();
} catch (err) {
console.error(err);
}
const now = new Date().getTime();
const total = endDate - startDate;
pct = 100 * ((now - startDate) / total);
if (pct > 0 && pct < 100) {
const isRecording = item.Type === 'Timer' || item.Type === 'Recording' || item.TimerId;
return getAutoTimeProgressHtml(pct, options, isRecording, startDate, endDate);
}
}
return '';
}
export function enablePlayedIndicator(item) {
return itemHelper.canMarkPlayed(item);
}
export function getPlayedIndicatorHtml(item) {
if (enablePlayedIndicator(item)) {
let userData = item.UserData || {};
if (userData.UnplayedItemCount) {
return '<div class="countIndicator indicator">' + userData.UnplayedItemCount + '</div>';
}
if (userData.PlayedPercentage && userData.PlayedPercentage >= 100 || (userData.Played)) {
return '<div class="playedIndicator indicator"><span class="material-icons indicatorIcon check"></span></div>';
}
}
return '';
}
export function getChildCountIndicatorHtml(item, options) {
const minCount = options && options.minCount ? options.minCount : 0;
if (item.ChildCount && item.ChildCount > minCount) {
return '<div class="countIndicator indicator">' + item.ChildCount + '</div>';
}
return '';
}
export function getTimerIndicator(item) {
let status;
if (item.Type === 'SeriesTimer') {
return '<span class="material-icons timerIndicator indicatorIcon fiber_smart_record"></span>';
} else if (item.TimerId || item.SeriesTimerId) {
status = item.Status || 'Cancelled';
} else if (item.Type === 'Timer') {
status = item.Status;
} else {
return '';
}
if (item.SeriesTimerId) {
if (status !== 'Cancelled') {
return '<span class="material-icons timerIndicator indicatorIcon fiber_smart_record"></span>';
}
return '<span class="material-icons timerIndicator timerIndicator-inactive indicatorIcon fiber_smart_record"></span>';
}
return '<span class="material-icons timerIndicator indicatorIcon fiber_manual_record"></span>';
}
export function getSyncIndicator(item) {
if (item.SyncPercent === 100) {
return '<div class="syncIndicator indicator fullSyncIndicator"><span class="material-icons indicatorIcon file_download"></span></div>';
} else if (item.SyncPercent != null) {
return '<div class="syncIndicator indicator emptySyncIndicator"><span class="material-icons indicatorIcon file_download"></span></div>';
}
return '';
}
export function getTypeIndicator(item) {
const iconT = {
'Video' : 'videocam',
'Folder' : 'folder',
'PhotoAlbum' : 'photo_album',
'Photo' : 'photo'
};
const icon = iconT[item.Type];
return icon ? '<div class="indicator videoIndicator"><span class="material-icons indicatorIcon ' + icon + '"></span></div>' : '';
}
export function getMissingIndicator(item) {
if (item.Type === 'Episode' && item.LocationType === 'Virtual') {
if (item.PremiereDate) {
try {
startDate = datetime.parseISO8601Date(item.StartDate).getTime();
endDate = datetime.parseISO8601Date(item.EndDate).getTime();
const premiereDate = datetime.parseISO8601Date(item.PremiereDate).getTime();
if (premiereDate > new Date().getTime()) {
return '<div class="unairedIndicator">Unaired</div>';
}
} catch (err) {
console.error(err);
}
var now = new Date().getTime();
var total = endDate - startDate;
pct = 100 * ((now - startDate) / total);
if (pct > 0 && pct < 100) {
var isRecording = item.Type === 'Timer' || item.Type === 'Recording' || item.TimerId;
return getAutoTimeProgressHtml(pct, options, isRecording, startDate, endDate);
}
}
return '';
return '<div class="missingIndicator">Missing</div>';
}
function enablePlayedIndicator(item) {
return itemHelper.canMarkPlayed(item);
}
return '';
}
function getPlayedIndicator(item) {
if (enablePlayedIndicator(item)) {
var userData = item.UserData || {};
if (userData.UnplayedItemCount) {
return '<div class="countIndicator indicator">' + userData.UnplayedItemCount + '</div>';
}
if (userData.PlayedPercentage && userData.PlayedPercentage >= 100 || (userData.Played)) {
return '<div class="playedIndicator indicator"><span class="material-icons indicatorIcon check"></span></div>';
}
}
return '';
}
function getCountIndicatorHtml(count) {
return '<div class="countIndicator indicator">' + count + '</div>';
}
function getChildCountIndicatorHtml(item, options) {
var minCount = 0;
if (options) {
minCount = options.minCount || minCount;
}
if (item.ChildCount && item.ChildCount > minCount) {
return getCountIndicatorHtml(item.ChildCount);
}
return '';
}
function getTimerIndicator(item) {
var status;
if (item.Type === 'SeriesTimer') {
return '<span class="material-icons timerIndicator indicatorIcon fiber_smart_record"></span>';
} else if (item.TimerId || item.SeriesTimerId) {
status = item.Status || 'Cancelled';
} else if (item.Type === 'Timer') {
status = item.Status;
} else {
return '';
}
if (item.SeriesTimerId) {
if (status !== 'Cancelled') {
return '<span class="material-icons timerIndicator indicatorIcon fiber_smart_record"></span>';
}
return '<span class="material-icons timerIndicator timerIndicator-inactive indicatorIcon fiber_smart_record"></span>';
}
return '<span class="material-icons timerIndicator indicatorIcon fiber_manual_record"></span>';
}
function getSyncIndicator(item) {
if (item.SyncPercent === 100) {
return '<div class="syncIndicator indicator fullSyncIndicator"><span class="material-icons indicatorIcon file_download"></span></div>';
} else if (item.SyncPercent != null) {
return '<div class="syncIndicator indicator emptySyncIndicator"><span class="material-icons indicatorIcon file_download"></span></div>';
}
return '';
}
function getTypeIndicator(item) {
if (item.Type === 'Video') {
return '<div class="indicator videoIndicator"><span class="material-icons indicatorIcon videocam"></span></div>';
}
if (item.Type === 'Folder') {
return '<div class="indicator videoIndicator"><span class="material-icons indicatorIcon folder"></span></div>';
}
if (item.Type === 'PhotoAlbum') {
return '<div class="indicator videoIndicator"><span class="material-icons indicatorIcon photo_album"></span></div>';
}
if (item.Type === 'Photo') {
return '<div class="indicator videoIndicator"><span class="material-icons indicatorIcon photo"></span></div>';
}
return '';
}
function getMissingIndicator(item) {
if (item.Type === 'Episode' && item.LocationType === 'Virtual') {
if (item.PremiereDate) {
try {
var premiereDate = datetime.parseISO8601Date(item.PremiereDate).getTime();
if (premiereDate > new Date().getTime()) {
return '<div class="unairedIndicator">Unaired</div>';
}
} catch (err) {
console.error(err);
}
}
return '<div class="missingIndicator">Missing</div>';
}
return '';
}
return {
getProgressHtml: getProgressHtml,
getProgressBarHtml: getProgressBarHtml,
getPlayedIndicatorHtml: getPlayedIndicator,
getChildCountIndicatorHtml: getChildCountIndicatorHtml,
enableProgressIndicator: enableProgressIndicator,
getTimerIndicator: getTimerIndicator,
enablePlayedIndicator: enablePlayedIndicator,
getSyncIndicator: getSyncIndicator,
getTypeIndicator: getTypeIndicator,
getMissingIndicator: getMissingIndicator
};
});
export default {
getProgressHtml: getProgressHtml,
getProgressBarHtml: getProgressBarHtml,
getPlayedIndicatorHtml: getPlayedIndicatorHtml,
getChildCountIndicatorHtml: getChildCountIndicatorHtml,
enableProgressIndicator: enableProgressIndicator,
getTimerIndicator: getTimerIndicator,
enablePlayedIndicator: enablePlayedIndicator,
getSyncIndicator: getSyncIndicator,
getTypeIndicator: getTypeIndicator,
getMissingIndicator: getMissingIndicator
};

View File

@ -450,7 +450,7 @@ define(['apphost', 'globalize', 'connectionManager', 'itemHelper', 'appRouter',
navigator.share({
title: item.Name,
text: item.Overview,
url: 'https://github.com/jellyfin/jellyfin'
url: `${apiClient.serverAddress()}/web/index.html#!/${appRouter.getRouteUrl(item)}`
});
break;
case 'album':

View File

@ -348,7 +348,7 @@ define(['dialogHelper', 'loading', 'connectionManager', 'require', 'globalize',
currentItemType = currentItem.Type;
var dialogOptions = {
size: 'fullscreen-border',
size: 'small',
removeOnClose: true,
scrollY: false
};
@ -429,7 +429,7 @@ define(['dialogHelper', 'loading', 'connectionManager', 'require', 'globalize',
require(['text!./itemidentifier.template.html'], function (template) {
var dialogOptions = {
size: 'fullscreen-border',
size: 'small',
removeOnClose: true,
scrollY: false
};

View File

@ -120,7 +120,12 @@ define(['globalize', 'dom', 'emby-checkbox', 'emby-select', 'emby-input'], funct
html += plugin.Name;
html += '</h3>';
html += '</div>';
index > 0 ? html += '<button type="button" is="paper-icon-button-light" title="' + globalize.translate('ButtonUp') + '" class="btnSortableMoveUp btnSortable" data-pluginindex="' + index + '"><span class="material-icons keyboard_arrow_up"></span></button>' : plugins.length > 1 && (html += '<button type="button" is="paper-icon-button-light" title="' + globalize.translate('ButtonDown') + '" class="btnSortableMoveDown btnSortable" data-pluginindex="' + index + '"><span class="material-icons keyboard_arrow_down"></span></button>'), html += '</div>';
if (index > 0) {
html += '<button type="button" is="paper-icon-button-light" title="' + globalize.translate('ButtonUp') + '" class="btnSortableMoveUp btnSortable" data-pluginindex="' + index + '"><span class="material-icons keyboard_arrow_up"></span></button>';
} else if (plugins.length > 1) {
html += '<button type="button" is="paper-icon-button-light" title="' + globalize.translate('ButtonDown') + '" class="btnSortableMoveDown btnSortable" data-pluginindex="' + index + '"><span class="material-icons keyboard_arrow_down"></span></button>';
}
html += '</div>';
});
html += '</div>';

View File

@ -426,7 +426,7 @@ define(['itemHelper', 'mediaInfo', 'indicators', 'connectionManager', 'layoutMan
html += '<div class="' + cssClass + '">';
const moreIcon = 'more_horiz';
const moreIcon = 'more_vert';
html += getTextLinesHtml(textlines, isLargeStyle);

View File

@ -182,12 +182,12 @@ define(['loading', 'dialogHelper', 'dom', 'jQuery', 'components/libraryoptionsed
currentResolve = resolve;
hasChanges = false;
var xhr = new XMLHttpRequest();
xhr.open('GET', 'components/medialibrarycreator/medialibrarycreator.template.html', true);
xhr.open('GET', 'components/mediaLibraryCreator/mediaLibraryCreator.template.html', true);
xhr.onload = function (e) {
var template = this.response;
var dlg = dialogHelper.createDialog({
size: 'medium-tall',
size: 'small',
modal: false,
removeOnClose: true,
scrollY: false

View File

@ -199,12 +199,12 @@ define(['jQuery', 'loading', 'dialogHelper', 'dom', 'components/libraryoptionsed
currentDeferred = deferred;
hasChanges = false;
var xhr = new XMLHttpRequest();
xhr.open('GET', 'components/medialibraryeditor/medialibraryeditor.template.html', true);
xhr.open('GET', 'components/mediaLibraryEditor/mediaLibraryEditor.template.html', true);
xhr.onload = function (e) {
var template = this.response;
var dlg = dialogHelper.createDialog({
size: 'medium-tall',
size: 'small',
modal: false,
removeOnClose: true,
scrollY: false

View File

@ -245,50 +245,6 @@ define(['itemHelper', 'dom', 'layoutManager', 'dialogHelper', 'datetime', 'loadi
});
}
function showMoreMenu(context, button, user) {
require(['itemContextMenu'], function (itemContextMenu) {
var item = currentItem;
itemContextMenu.show({
item: item,
positionTo: button,
edit: false,
editImages: true,
editSubtitles: true,
sync: false,
share: false,
play: false,
queue: false,
user: user
}).then(function (result) {
if (result.deleted) {
afterDeleted(context, item);
} else if (result.updated) {
reload(context, item.Id, item.ServerId);
}
});
});
}
function afterDeleted(context, item) {
var parentId = item.ParentId || item.SeasonId || item.SeriesId;
if (parentId) {
reload(context, parentId, item.ServerId);
} else {
require(['appRouter'], function (appRouter) {
appRouter.goHome();
});
}
}
function onEditorClick(e) {
var btnRemoveFromEditorList = dom.parentWithClass(e.target, 'btnRemoveFromEditorList');
@ -307,6 +263,12 @@ define(['itemHelper', 'dom', 'layoutManager', 'dialogHelper', 'datetime', 'loadi
return connectionManager.getApiClient(currentItem.ServerId);
}
function bindAll(elems, eventName, fn) {
for (var i = 0, length = elems.length; i < length; i++) {
elems[i].addEventListener(eventName, fn);
}
}
function init(context, apiClient) {
context.querySelector('.externalIds').addEventListener('click', function (e) {
@ -322,19 +284,16 @@ define(['itemHelper', 'dom', 'layoutManager', 'dialogHelper', 'datetime', 'loadi
}
});
context.querySelector('.btnCancel').addEventListener('click', function () {
if (!layoutManager.desktop) {
context.querySelector('.btnBack').classList.remove('hide');
context.querySelector('.btnClose').classList.add('hide');
}
bindAll(context.querySelectorAll('.btnCancel'), 'click', function (event) {
event.preventDefault();
closeDialog(false);
});
context.querySelector('.btnMore').addEventListener('click', function (e) {
getApiClient().getCurrentUser().then(function (user) {
showMoreMenu(context, e.target, user);
});
});
context.querySelector('.btnHeaderSave').addEventListener('click', function (e) {
context.querySelector('.btnSave').click();
@ -349,8 +308,8 @@ define(['itemHelper', 'dom', 'layoutManager', 'dialogHelper', 'datetime', 'loadi
}
});
context.removeEventListener('click', onEditorClick);
context.addEventListener('click', onEditorClick);
context.removeEventListener('submit', onEditorClick);
context.addEventListener('submit', onEditorClick);
var form = context.querySelector('form');
form.removeEventListener('submit', onSubmit);
@ -1067,7 +1026,7 @@ define(['itemHelper', 'dom', 'layoutManager', 'dialogHelper', 'datetime', 'loadi
function show(itemId, serverId, resolve, reject) {
loading.show();
require(['text!./metadataeditor.template.html'], function (template) {
require(['text!./metadataEditor.template.html'], function (template) {
var dialogOptions = {
removeOnClose: true,
@ -1077,7 +1036,7 @@ define(['itemHelper', 'dom', 'layoutManager', 'dialogHelper', 'datetime', 'loadi
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'medium-tall';
dialogOptions.size = 'small';
}
var dlg = dialogHelper.createDialog(dialogOptions);
@ -1124,7 +1083,7 @@ define(['itemHelper', 'dom', 'layoutManager', 'dialogHelper', 'datetime', 'loadi
loading.show();
require(['text!./metadataeditor.template.html'], function (template) {
require(['text!./metadataEditor.template.html'], function (template) {
elem.innerHTML = globalize.translateDocument(template, 'core');

View File

@ -1,5 +1,5 @@
<div class="formDialogHeader">
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1"><span class="material-icons arrow_back"></span></button>
<button is="paper-icon-button-light" class="btnCancel btnBack autoSize hide" tabindex="-1"><span class="material-icons arrow_back"></span></button>
<h3 class="formDialogHeaderTitle">
${Edit}
</h3>
@ -8,8 +8,8 @@
<span class="material-icons check"></span>
<span>${Save}</span>
</button>
<button is="paper-icon-button-light" class="btnMore autoSize" tabindex="-1">
<span class="material-icons more_horiz"></span>
<button is="paper-icon-button-light" class="btnCancel btnClose autoSize" tabindex="-1">
<span class="material-icons close"></span>
</button>
</div>
</div>
@ -57,11 +57,13 @@
<div id="fldAlbum" class="hide inputContainer">
<input is="emby-input" id="txtAlbum" type="text" label="${LabelAlbum}" />
</div>
<div id="fldParentIndexNumber" class="hide inputContainer">
<input is="emby-input" id="txtParentIndexNumber" type="number" />
</div>
<div id="fldIndexNumber" class="hide inputContainer">
<input is="emby-input" id="txtIndexNumber" type="number" pattern="[0-9]*" />
<div class="inlineForm">
<div id="fldParentIndexNumber" class="hide inputContainer">
<input is="emby-input" id="txtParentIndexNumber" type="number" />
</div>
<div id="fldIndexNumber" class="hide inputContainer">
<input is="emby-input" id="txtIndexNumber" type="number" pattern="[0-9]*" />
</div>
</div>
<div id="fldCommunityRating" class="hide inputContainer">
<input is="emby-input" id="txtCommunityRating" type="number" step=".1" min="0" max="10" label="${LabelCommunityRating}" />
@ -129,24 +131,28 @@
<div id="fldSeriesRuntime" class="inputContainer hide">
<input is="emby-input" id="txtSeriesRuntime" type="number" label="${LabelRuntimeMinutes}" />
</div>
<div id="fldOfficialRating" class="selectContainer hide">
<select is="emby-select" id="selectOfficialRating" label="${LabelParentalRating}"></select>
<div class="inlineForm">
<div id="fldOfficialRating" class="selectContainer hide">
<select is="emby-select" id="selectOfficialRating" label="${LabelParentalRating}"></select>
</div>
<div id="fldCustomRating" class="selectContainer hide">
<select is="emby-select" id="selectCustomRating" label="${LabelCustomRating}"></select>
</div>
</div>
<div id="fldCustomRating" class="selectContainer hide">
<select is="emby-select" id="selectCustomRating" label="${LabelCustomRating}"></select>
</div>
<div id="fldOriginalAspectRatio" class="inputContainer hide">
<input is="emby-input" id="txtOriginalAspectRatio" type="text" label="${LabelOriginalAspectRatio}" />
</div>
<div id="fld3dFormat" class="selectContainer hide">
<select is="emby-select" id="select3dFormat" label="${Label3DFormat}">
<option value=""></option>
<option value="HalfSideBySide">HSBS</option>
<option value="HalfTopAndBottom">HTAB</option>
<option value="FullSideBySide">FSBS</option>
<option value="FullTopAndBottom">FTAB</option>
<option value="MVC">MVC</option>
</select>
<div class="inlineForm">
<div id="fldOriginalAspectRatio" class="inputContainer hide">
<input is="emby-input" id="txtOriginalAspectRatio" type="text" label="${LabelOriginalAspectRatio}" />
</div>
<div id="fld3dFormat" class="selectContainer hide">
<select is="emby-select" id="select3dFormat" label="${Label3DFormat}">
<option value=""></option>
<option value="HalfSideBySide">HSBS</option>
<option value="HalfTopAndBottom">HTAB</option>
<option value="FullSideBySide">FSBS</option>
<option value="FullTopAndBottom">FTAB</option>
<option value="MVC">MVC</option>
</select>
</div>
</div>
<div id="fldDisplayOrder" class="fldDisplaySetting selectContainer hide">
@ -160,14 +166,16 @@
<h2>
${HeaderSpecialEpisodeInfo}
</h2>
<div class="inputContainer">
<input is="emby-input" id="txtAirsBeforeSeason" type="number" pattern="[0-9]*" label="${LabelAirsBeforeSeason}" />
</div>
<div class="inputContainer">
<input is="emby-input" id="txtAirsAfterSeason" type="number" pattern="[0-9]*" label="${LabelAirsAfterSeason}" />
</div>
<div class="inputContainer">
<input is="emby-input" id="txtAirsBeforeEpisode" type="number" pattern="[0-9]*" label="${LabelAirsBeforeEpisode}" />
<div class="inlineForm">
<div class="inputContainer">
<input is="emby-input" id="txtAirsBeforeSeason" type="number" pattern="[0-9]*" label="${LabelAirsBeforeSeason}" />
</div>
<div class="inputContainer">
<input is="emby-input" id="txtAirsAfterSeason" type="number" pattern="[0-9]*" label="${LabelAirsAfterSeason}" />
</div>
<div class="inputContainer">
<input is="emby-input" id="txtAirsBeforeEpisode" type="number" pattern="[0-9]*" label="${LabelAirsBeforeEpisode}" />
</div>
</div>
</div>
@ -240,8 +248,11 @@
</div>
<br />
<div class="formDialogFooter">
<button is="emby-button" class="raised button-cancel block btnCancel formDialogFooterItem">
<span>${Cancel}</span>
</button>
<button is="emby-button" type="submit" class="raised button-submit block btnSave formDialogFooterItem">
<span>${Save}</span>
<span>${SaveChanges}</span>
</button>
</div>

View File

@ -11,7 +11,7 @@ define(['dialogHelper', 'layoutManager', 'globalize', 'require', 'paper-icon-but
function show(person) {
return new Promise(function (resolve, reject) {
require(['text!./personeditor.template.html'], function (template) {
require(['text!./personEditor.template.html'], function (template) {
var dialogOptions = {
removeOnClose: true,
@ -21,7 +21,7 @@ define(['dialogHelper', 'layoutManager', 'globalize', 'require', 'paper-icon-but
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'medium-tall';
dialogOptions.size = 'small';
}
var dlg = dialogHelper.createDialog(dialogOptions);

View File

@ -1,4 +1,4 @@
define(['browser', 'appStorage', 'apphost', 'loading', 'connectionManager', 'globalize', 'appRouter', 'dom', 'css!./multiselect'], function (browser, appStorage, appHost, loading, connectionManager, globalize, appRouter, dom) {
define(['browser', 'appStorage', 'apphost', 'loading', 'connectionManager', 'globalize', 'appRouter', 'dom', 'css!./multiSelect'], function (browser, appStorage, appHost, loading, connectionManager, globalize, appRouter, dom) {
'use strict';
var selectedItems = [];
@ -129,7 +129,7 @@ define(['browser', 'appStorage', 'apphost', 'loading', 'connectionManager', 'glo
html += '<button is="paper-icon-button-light" class="btnCloseSelectionPanel autoSize"><span class="material-icons close"></span></button>';
html += '<h1 class="itemSelectionCount"></h1>';
const moreIcon = 'more_horiz';
const moreIcon = 'more_vert';
html += '<button is="paper-icon-button-light" class="btnSelectionPanelOptions autoSize" style="margin-left:auto;"><span class="material-icons ' + moreIcon + '"></span></button>';
selectionCommandsPanel.innerHTML = html;

View File

@ -46,7 +46,7 @@ define(['serverNotifications', 'playbackManager', 'events', 'globalize', 'requir
function showNonPersistentNotification(title, options, timeoutMs) {
try {
var notif = new Notification(title, options);
var notif = new Notification(title, options); /* eslint-disable-line compat/compat */
if (notif.show) {
notif.show();

View File

@ -244,7 +244,7 @@ define(['require', 'datetime', 'itemHelper', 'events', 'browser', 'imageLoader',
return new Promise(function (resolve, reject) {
require(['appFooter-shared', 'itemShortcuts', 'css!./nowplayingbar.css', 'emby-slider'], function (appfooter, itemShortcuts) {
require(['appFooter-shared', 'itemShortcuts', 'css!./nowPlayingBar.css', 'emby-slider'], function (appfooter, itemShortcuts) {
var parentContainer = appfooter.element;
nowPlayingBarElement = parentContainer.querySelector('.nowPlayingBar');

View File

@ -1,23 +1,18 @@
define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackManager', 'appRouter', 'appSettings', 'connectionManager'], function (browser, require, events, appHost, loading, dom, playbackManager, appRouter, appSettings, connectionManager) {
'use strict';
import connectionManager from 'connectionManager';
function PhotoPlayer() {
var self = this;
self.name = 'Photo Player';
self.type = 'mediaplayer';
self.id = 'photoplayer';
// Let any players created by plugins take priority
self.priority = 1;
export default class PhotoPlayer {
constructor() {
this.name = 'Photo Player';
this.type = 'mediaplayer';
this.id = 'photoplayer';
this.priority = 1;
}
PhotoPlayer.prototype.play = function (options) {
play(options) {
return new Promise(function (resolve, reject) {
require(['slideshow'], function (slideshow) {
import('slideshow').then(({default: slideshow}) => {
var index = options.startIndex || 0;
@ -41,12 +36,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
});
});
});
};
}
PhotoPlayer.prototype.canPlayMediaType = function (mediaType) {
canPlayMediaType(mediaType) {
return (mediaType || '').toLowerCase() === 'photo';
};
return PhotoPlayer;
});
}
}

View File

@ -119,6 +119,7 @@ import connectionManager from 'connectionManager';
const canSeek = playState.CanSeek || false;
if ('mediaSession' in navigator) {
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.metadata = new MediaMetadata({
title: title,
artist: artist,
@ -179,6 +180,7 @@ import connectionManager from 'connectionManager';
function hideMediaControls() {
if ('mediaSession' in navigator) {
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.metadata = null;
} else {
window.NativeShell.hideMediaSession();
@ -210,26 +212,32 @@ import connectionManager from 'connectionManager';
}
if ('mediaSession' in navigator) {
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('previoustrack', function () {
execute('previousTrack');
});
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('nexttrack', function () {
execute('nextTrack');
});
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('play', function () {
execute('unpause');
});
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('pause', function () {
execute('pause');
});
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('seekbackward', function () {
execute('rewind');
});
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('seekforward', function () {
execute('fastForward');
});

View File

@ -54,6 +54,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
if (!serverId) {
// Not a server item
// We can expand on this later and possibly report them
events.trigger(playbackManagerInstance, 'reportplayback', [false]);
return;
}
@ -77,7 +78,11 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
}
var apiClient = connectionManager.getApiClient(serverId);
apiClient[method](info);
var reportPlaybackPromise = apiClient[method](info);
// Notify that report has been sent
reportPlaybackPromise.then(() => {
events.trigger(playbackManagerInstance, 'reportplayback', [true]);
});
}
function getPlaylistSync(playbackManagerInstance, player) {
@ -1124,7 +1129,6 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
}
self.canPlay = function (item) {
var itemType = item.Type;
if (itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
@ -1138,7 +1142,6 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
}
if (itemType === 'Program') {
if (!item.EndDate || !item.StartDate) {
return false;
}
@ -2182,7 +2185,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
// Only used internally
self.getCurrentTicks = getCurrentTicks;
function playPhotos(items, options, user) {
function playOther(items, options, user) {
var playStartIndex = options.startIndex || 0;
var player = getPlayer(items[playStartIndex], options);
@ -2211,9 +2214,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
return Promise.reject();
}
if (firstItem.MediaType === 'Photo') {
if (firstItem.MediaType === 'Photo' || firstItem.MediaType === 'Book') {
return playPhotos(items, options, user);
return playOther(items, options, user);
}
var apiClient = connectionManager.getApiClient(firstItem.ServerId);
@ -3775,6 +3778,20 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
}
};
PlaybackManager.prototype.setPlaybackRate = function (value, player = this._currentPlayer) {
if (player && player.setPlaybackRate) {
player.setPlaybackRate(value);
}
};
PlaybackManager.prototype.getPlaybackRate = function (player = this._currentPlayer) {
if (player && player.getPlaybackRate) {
return player.getPlaybackRate();
}
return null;
};
PlaybackManager.prototype.instantMix = function (item, player) {
player = player || this._currentPlayer;
@ -3885,6 +3902,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
if (player.supports('SetAspectRatio')) {
list.push('SetAspectRatio');
}
if (player.supports('PlaybackRate')) {
list.push('PlaybackRate');
}
}
return list;

View File

@ -7,11 +7,13 @@ define(['connectionManager', 'actionsheet', 'datetime', 'playbackManager', 'glob
return stream.Type === 'Video';
})[0];
var videoWidth = videoStream ? videoStream.Width : null;
var videoHeight = videoStream ? videoStream.Height : null;
var options = qualityoptions.getVideoQualityOptions({
currentMaxBitrate: playbackManager.getMaxStreamingBitrate(player),
isAutomaticBitrateEnabled: playbackManager.enableAutomaticBitrateDetection(player),
videoWidth: videoWidth,
videoHeight: videoHeight,
enableAuto: true
});
@ -91,11 +93,13 @@ define(['connectionManager', 'actionsheet', 'datetime', 'playbackManager', 'glob
})[0];
var videoWidth = videoStream ? videoStream.Width : null;
var videoHeight = videoStream ? videoStream.Height : null;
var options = qualityoptions.getVideoQualityOptions({
currentMaxBitrate: playbackManager.getMaxStreamingBitrate(player),
isAutomaticBitrateEnabled: playbackManager.enableAutomaticBitrateDetection(player),
videoWidth: videoWidth,
videoHeight: videoHeight,
enableAuto: true
});

View File

@ -204,6 +204,9 @@ define(['require', 'browser', 'appSettings', 'apphost', 'focusManager', 'quality
fillChromecastQuality(context.querySelector('.selectChromecastVideoQuality'));
var selectChromecastVersion = context.querySelector('.selectChromecastVersion');
selectChromecastVersion.value = userSettings.chromecastVersion();
var selectSkipForwardLength = context.querySelector('.selectSkipForwardLength');
fillSkipLengths(selectSkipForwardLength);
selectSkipForwardLength.value = userSettings.skipForwardLength();
@ -234,6 +237,7 @@ define(['require', 'browser', 'appSettings', 'apphost', 'focusManager', 'quality
userSettingsInstance.enableCinemaMode(context.querySelector('.chkEnableCinemaMode').checked);
userSettingsInstance.enableNextVideoInfoOverlay(context.querySelector('.chkEnableNextVideoOverlay').checked);
userSettingsInstance.chromecastVersion(context.querySelector('.selectChromecastVersion').value);
userSettingsInstance.skipForwardLength(context.querySelector('.selectSkipForwardLength').value);
userSettingsInstance.skipBackLength(context.querySelector('.selectSkipBackLength').value);
@ -285,7 +289,7 @@ define(['require', 'browser', 'appSettings', 'apphost', 'focusManager', 'quality
function embed(options, self) {
require(['text!./playbacksettings.template.html'], function (template) {
require(['text!./playbackSettings.template.html'], function (template) {
options.element.innerHTML = globalize.translateDocument(template, 'core');

View File

@ -1,12 +1,13 @@
<form style="margin: 0 auto;">
<div class="verticalSection verticalSection-extrabottompadding">
<h2 class="sectionTitle">
${HeaderAudioSettings}
</h2>
<div class="selectContainer">
<select is="emby-select" id="selectAudioLanguage" label="${LabelAudioLanguagePreference}"></select>
</div>
<label class="checkboxContainer">
<input type="checkbox" is="emby-checkbox" class="chkPlayDefaultAudioTrack" />
<span>${LabelPlayDefaultAudioTrack}</span>
@ -18,12 +19,15 @@
<h2 class="sectionTitle">
${HeaderVideoQuality}
</h2>
<div class="selectContainer fldVideoInNetworkQuality hide">
<select is="emby-select" class="selectVideoInNetworkQuality" label="${LabelHomeNetworkQuality}"></select>
</div>
<div class="selectContainer fldVideoInternetQuality hide">
<select is="emby-select" class="selectVideoInternetQuality" label="${LabelInternetQuality}"></select>
</div>
<div class="selectContainer fldChromecastQuality hide">
<select is="emby-select" class="selectChromecastVideoQuality" label="${LabelMaxChromecastBitrate}"></select>
</div>
@ -33,6 +37,7 @@
<h2>
${HeaderMusicQuality}
</h2>
<div class="selectContainer">
<select is="emby-select" class="selectMusicInternetQuality" label="${LabelInternetQuality}"></select>
</div>
@ -43,6 +48,7 @@
<h2 class="sectionTitle">
${TabAdvanced}
</h2>
<div class="checkboxContainer checkboxContainer-withDescription cinemaModeOptions">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEnableCinemaMode" />
@ -50,12 +56,14 @@
</label>
<div class="fieldDescription checkboxFieldDescription">${CinemaModeConfigurationHelp}</div>
</div>
<div class="checkboxContainer fldEpisodeAutoPlay hide">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEpisodeAutoPlay" />
<span>${PlayNextEpisodeAutomatically}</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldEnableNextVideoOverlay hide">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEnableNextVideoOverlay" />
@ -74,6 +82,13 @@
</div>
</div>
<div class="selectContainer">
<select is="emby-select" class="selectChromecastVersion" label="${LabelChromecastVersion}">
<option value="stable">${LabelStable}</option>
<option value="nightly">${LabelNightly}</option>
</select>
</div>
<div class="selectContainer">
<select is="emby-select" class="selectSkipForwardLength" label="${LabelSkipForwardLength}"></select>
</div>

View File

@ -1,4 +1,4 @@
define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, playMethodHelper, layoutManager, serverNotifications) {
define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncPlayManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, syncPlayManager, playMethodHelper, layoutManager, serverNotifications) {
'use strict';
function init(instance) {
@ -327,6 +327,28 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth
return sessionStats;
}
function getSyncPlayStats() {
var syncStats = [];
var stats = syncPlayManager.getStats();
syncStats.push({
label: globalize.translate('LabelSyncPlayTimeOffset'),
value: stats.TimeOffset + globalize.translate('MillisecondsUnit')
});
syncStats.push({
label: globalize.translate('LabelSyncPlayPlaybackDiff'),
value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit')
});
syncStats.push({
label: globalize.translate('LabelSyncPlaySyncMethod'),
value: stats.SyncMethod
});
return syncStats;
}
function getStats(instance, player) {
var statsPromise = player.getStats ? player.getStats() : Promise.resolve({});
@ -383,6 +405,13 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth
name: 'Original Media Info'
});
if (syncPlayManager.isSyncPlayEnabled()) {
categories.push({
stats: getSyncPlayStats(),
name: 'SyncPlay Info'
});
}
return Promise.resolve(categories);
});
}

View File

@ -1,75 +1,71 @@
define(['actionsheet', 'datetime', 'playbackManager', 'globalize', 'appSettings'], function (actionsheet, datetime, playbackManager, globalize, appSettings) {
'use strict';
import actionsheet from 'actionsheet';
import datetime from 'datetime';
import playbackManager from 'playbackManager';
import globalize from 'globalize';
function show(options) {
export function show(options) {
var item = options.item;
var item = options.item;
var itemType = item.Type;
var isFolder = item.IsFolder;
var itemId = item.Id;
var channelId = item.ChannelId;
var serverId = item.ServerId;
var resumePositionTicks = item.UserData ? item.UserData.PlaybackPositionTicks : null;
var resumePositionTicks = item.UserData ? item.UserData.PlaybackPositionTicks : null;
var playableItemId = itemType === 'Program' ? channelId : itemId;
var playableItemId = item.Type === 'Program' ? item.ChannelId : item.Id;
if (!resumePositionTicks || isFolder) {
playbackManager.play({
ids: [playableItemId],
serverId: serverId
});
return;
}
var menuItems = [];
menuItems.push({
name: globalize.translate('ResumeAt', datetime.getDisplayRunningTime(resumePositionTicks)),
id: 'resume'
});
menuItems.push({
name: globalize.translate('PlayFromBeginning'),
id: 'play'
});
actionsheet.show({
items: menuItems,
positionTo: options.positionTo
}).then(function (id) {
switch (id) {
case 'play':
playbackManager.play({
ids: [playableItemId],
serverId: serverId
});
break;
case 'resume':
playbackManager.play({
ids: [playableItemId],
startPositionTicks: resumePositionTicks,
serverId: serverId
});
break;
case 'queue':
playbackManager.queue({
items: [item]
});
break;
case 'shuffle':
playbackManager.shuffle(item);
break;
default:
break;
}
if (!resumePositionTicks || item.IsFolder) {
playbackManager.play({
ids: [playableItemId],
serverId: item.ServerId
});
return;
}
return {
show: show
};
});
var menuItems = [];
menuItems.push({
name: globalize.translate('ResumeAt', datetime.getDisplayRunningTime(resumePositionTicks)),
id: 'resume'
});
menuItems.push({
name: globalize.translate('PlayFromBeginning'),
id: 'play'
});
actionsheet.show({
items: menuItems,
positionTo: options.positionTo
}).then(function (id) {
switch (id) {
case 'play':
playbackManager.play({
ids: [playableItemId],
serverId: item.ServerId
});
break;
case 'resume':
playbackManager.play({
ids: [playableItemId],
startPositionTicks: resumePositionTicks,
serverId: item.ServerId
});
break;
case 'queue':
playbackManager.queue({
items: [item]
});
break;
case 'shuffle':
playbackManager.shuffle(item);
break;
default:
break;
}
});
}
export default {
show: show
};

View File

@ -1,10 +1,10 @@
define(['events'], function (events) {
define(['events', 'globalize'], function (events, globalize) {
'use strict';
// TODO: replace with each plugin version
var cacheParam = new Date().getTime();
function loadStrings(plugin, globalize) {
function loadStrings(plugin) {
var strings = plugin.getTranslations ? plugin.getTranslations() : [];
return globalize.loadStrings({
name: plugin.id || plugin.packageName,
@ -25,68 +25,78 @@ define(['events'], function (events) {
this.pluginsList = [];
}
PluginManager.prototype.loadPlugin = function (url) {
PluginManager.prototype.loadPlugin = function(pluginSpec) {
console.debug('Loading plugin: ' + url);
var instance = this;
return new Promise(function (resolve, reject) {
function registerPlugin(plugin) {
instance.register(plugin);
require([url, 'globalize', 'appRouter'], function (pluginFactory, globalize, appRouter) {
var plugin = new pluginFactory();
// See if it's already installed
var existing = instance.pluginsList.filter(function (p) {
return p.id === plugin.id;
})[0];
if (existing) {
resolve(url);
return;
}
plugin.installUrl = url;
var urlLower = url.toLowerCase();
if (urlLower.indexOf('http:') === -1 && urlLower.indexOf('https:') === -1 && urlLower.indexOf('file:') === -1) {
if (url.indexOf(appRouter.baseUrl()) !== 0) {
url = appRouter.baseUrl() + '/' + url;
}
}
var separatorIndex = Math.max(url.lastIndexOf('/'), url.lastIndexOf('\\'));
plugin.baseUrl = url.substring(0, separatorIndex);
var paths = {};
paths[plugin.id] = plugin.baseUrl;
requirejs.config({
waitSeconds: 0,
paths: paths
if (plugin.getRoutes) {
plugin.getRoutes().forEach(function (route) {
definePluginRoute(instance, route, plugin);
});
}
instance.register(plugin);
if (plugin.type === 'skin') {
if (plugin.getRoutes) {
plugin.getRoutes().forEach(function (route) {
definePluginRoute(instance, route, plugin);
// translations won't be loaded for skins until needed
return Promise.resolve(plugin);
} else {
return new Promise((resolve, reject) => {
loadStrings(plugin)
.then(function () {
resolve(plugin);
})
.catch(reject);
});
}
}
if (typeof pluginSpec === 'string') {
console.debug('Loading plugin (via deprecated requirejs method): ' + pluginSpec);
return new Promise(function (resolve, reject) {
require([pluginSpec], (pluginFactory) => {
var plugin = pluginFactory.default ? new pluginFactory.default() : new pluginFactory();
// See if it's already installed
var existing = instance.pluginsList.filter(function (p) {
return p.id === plugin.id;
})[0];
if (existing) {
resolve(pluginSpec);
}
plugin.installUrl = pluginSpec;
var separatorIndex = Math.max(pluginSpec.lastIndexOf('/'), pluginSpec.lastIndexOf('\\'));
plugin.baseUrl = pluginSpec.substring(0, separatorIndex);
var paths = {};
paths[plugin.id] = plugin.baseUrl;
requirejs.config({
waitSeconds: 0,
paths: paths
});
}
if (plugin.type === 'skin') {
// translations won't be loaded for skins until needed
resolve(plugin);
} else {
loadStrings(plugin, globalize).then(function () {
resolve(plugin);
}, reject);
}
registerPlugin(plugin).then(resolve).catch(reject);
});
});
});
} else if (pluginSpec.then) {
return pluginSpec.then(pluginBuilder => {
return pluginBuilder();
}).then(plugin => {
console.debug(`Plugin loaded: ${plugin.id}`);
return registerPlugin(plugin);
});
} else {
const err = new Error('Plugins have to be a Promise that resolves to a plugin builder function or a requirejs urls (deprecated)');
console.error(err);
return Promise.reject(err);
}
};
// In lieu of automatic discovery, plugins will register dynamic objects

View File

@ -5,6 +5,13 @@ define(['globalize'], function (globalize) {
var maxStreamingBitrate = options.currentMaxBitrate;
var videoWidth = options.videoWidth;
var videoHeight = options.videoHeight;
// If the aspect ratio is less than 16/9 (1.77), set the width as if it were pillarboxed.
// 4:3 1440x1080 -> 1920x1080
if (videoWidth / videoHeight < 16 / 9) {
videoWidth = videoHeight * (16 / 9);
}
var maxAllowedWidth = videoWidth || 4096;
//var maxAllowedHeight = videoHeight || 2304;

View File

@ -140,7 +140,7 @@ define(['browser', 'datetime', 'backdrop', 'libraryBrowser', 'listView', 'imageL
}
if (item.SeriesName != null) {
var seriesName = item.SeriesName;
if (item.SeriesId !=null) {
if (item.SeriesId != null) {
context.querySelector('.nowPlayingSerie').innerHTML = '<a class="button-link emby-button" is="emby-linkbutton" href="itemdetails.html?id=' + item.SeriesId + `&amp;serverId=${nowPlayingServerId}">${seriesName}</a>`;
} else {
context.querySelector('.nowPlayingSerie').innerHTML = seriesName;

View File

@ -92,7 +92,7 @@ import layoutManager from 'layoutManager';
* @return {number} Eased value in range [0, 1].
*/
function ease(t) {
return t*(2 - t); // easeOutQuad === ease-out
return t * (2 - t); // easeOutQuad === ease-out
}
/**
@ -402,8 +402,8 @@ import layoutManager from 'layoutManager';
k = ease(k);
const x = ox + dx*k;
const y = oy + dy*k;
const x = ox + dx * k;
const y = oy + dy * k;
builtinScroll(xScroller, x, yScroller, y, false);

View File

@ -1,4 +1,4 @@
define(['apphost', 'userSettings', 'browser', 'events', 'pluginManager', 'backdrop', 'globalize', 'require', 'appSettings'], function (appHost, userSettings, browser, events, pluginManager, backdrop, globalize, require, appSettings) {
define(['apphost', 'userSettings', 'browser', 'events', 'backdrop', 'globalize', 'require', 'appSettings'], function (appHost, userSettings, browser, events, backdrop, globalize, require, appSettings) {
'use strict';
var themeStyleElement;
@ -57,7 +57,6 @@ define(['apphost', 'userSettings', 'browser', 'events', 'pluginManager', 'backdr
var selectedTheme;
for (var i = 0, length = themes.length; i < length; i++) {
var theme = themes[i];
if (theme[isDefaultProperty]) {
defaultTheme = theme;
@ -137,6 +136,8 @@ define(['apphost', 'userSettings', 'browser', 'events', 'pluginManager', 'backdr
function onViewBeforeShow(e) {
if (e.detail && e.detail.type === 'video-osd') {
// This removes the space that the scrollbar takes while playing a video
document.body.classList.remove('force-scroll');
return;
}
@ -155,6 +156,9 @@ define(['apphost', 'userSettings', 'browser', 'events', 'pluginManager', 'backdr
}
}
}
// This keeps the scrollbar always present in all pages, so we avoid clipping while switching between pages
// that need the scrollbar and pages that don't.
document.body.classList.add('force-scroll');
}
document.addEventListener('viewshow', onViewBeforeShow);

View File

@ -438,6 +438,9 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f
inputManager.off(window, onInputCommand);
document.removeEventListener((window.PointerEvent ? 'pointermove' : 'mousemove'), onPointerMove);
// Shows page scrollbar
document.body.classList.remove('hide-scroll');
document.body.classList.add('force-scroll');
}
/**
@ -603,6 +606,9 @@ define(['dialogHelper', 'inputManager', 'connectionManager', 'layoutManager', 'f
*/
self.show = function () {
createElements(options);
// Hides page scrollbar
document.body.classList.remove('force-scroll');
document.body.classList.add('hide-scroll');
};
/**

View File

@ -0,0 +1,189 @@
import events from 'events';
import connectionManager from 'connectionManager';
import playbackManager from 'playbackManager';
import syncPlayManager from 'syncPlayManager';
import loading from 'loading';
import toast from 'toast';
import actionsheet from 'actionsheet';
import globalize from 'globalize';
import playbackPermissionManager from 'playbackPermissionManager';
/**
* Gets active player id.
* @returns {string} The player's id.
*/
function getActivePlayerId () {
var 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.sendSyncPlayCommand(sessionId, 'ListGroups').then(function (response) {
response.json().then(function (groups) {
var 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;
}
var menuOptions = {
title: globalize.translate('HeaderSyncPlaySelectGroup'),
items: menuItems,
positionTo: button,
resolveOnClick: true,
border: true
};
actionsheet.show(menuOptions).then(function (id) {
if (id == 'new-group') {
apiClient.sendSyncPlayCommand(sessionId, 'NewGroup');
} else {
apiClient.sendSyncPlayCommand(sessionId, 'JoinGroup', {
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')
}];
var menuOptions = {
title: globalize.translate('HeaderSyncPlayEnabled'),
items: menuItems,
positionTo: button,
resolveOnClick: true,
border: true
};
actionsheet.show(menuOptions).then(function (id) {
if (id == 'leave-group') {
apiClient.sendSyncPlayCommand(sessionId, 'LeaveGroup');
}
}).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 = connectionManager.currentApiClient();
connectionManager.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')
});
});
}

View File

@ -0,0 +1,51 @@
/**
* Creates an audio element that plays a silent sound.
* @returns {HTMLMediaElement} The audio element.
*/
function createTestMediaElement () {
const elem = document.createElement('audio');
elem.classList.add('testMediaPlayerAudio');
elem.classList.add('hide');
document.body.appendChild(elem);
elem.volume = 1; // Volume should not be zero to trigger proper permissions
elem.src = 'assets/audio/silence.mp3'; // Silent sound
return elem;
}
/**
* Destroys a media element.
* @param {HTMLMediaElement} elem The element to destroy.
*/
function destroyTestMediaElement (elem) {
elem.pause();
elem.remove();
}
/**
* Class that manages the playback permission.
*/
class PlaybackPermissionManager {
/**
* Tests playback permission. Grabs the permission when called inside a click event (or any other valid user interaction).
* @returns {Promise} Promise that resolves succesfully if playback permission is allowed.
*/
check () {
return new Promise((resolve, reject) => {
const media = createTestMediaElement();
media.play().then(() => {
resolve();
}).catch((error) => {
reject(error);
}).finally(() => {
destroyTestMediaElement(media);
});
});
}
}
/** PlaybackPermissionManager singleton. */
export default new PlaybackPermissionManager();

View File

@ -0,0 +1,838 @@
/**
* Module that manages the SyncPlay feature.
* @module components/syncplay/syncPlayManager
*/
import events from 'events';
import connectionManager from 'connectionManager';
import playbackManager from 'playbackManager';
import timeSyncManager from 'timeSyncManager';
import toast from 'toast';
import globalize from 'globalize';
/**
* 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() {
var 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 = connectionManager.currentApiClient();
const sessionId = getActivePlayerId();
if (!sessionId) {
this.signalError();
toast({
text: globalize.translate('MessageSyncPlayErrorMissingSession')
});
return;
}
apiClient.sendSyncPlayCommand(sessionId, 'UpdatePing', {
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 () {
var 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(() => {
var 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.sendSyncPlayCommand(sessionId, 'JoinGroup', {
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) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncPlayCommand(sessionId, 'PlayRequest');
}
/**
* Overrides PlaybackManager's pause method.
*/
pauseRequest (player) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncPlayCommand(sessionId, 'PauseRequest');
// Pause locally as well, to give the user some little control
playbackManager._localUnpause(player);
}
/**
* Overrides PlaybackManager's seek method.
*/
seekRequest (PositionTicks, player) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncPlayCommand(sessionId, 'SeekRequest', {
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();
// 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();

View File

@ -0,0 +1,207 @@
/**
* Module that manages time syncing with server.
* @module components/syncplay/timeSyncManager
*/
import events from 'events';
import connectionManager from 'connectionManager';
/**
* Time estimation
*/
const NumberOfTrackedMeasurements = 8;
const PollingIntervalGreedy = 1000; // milliseconds
const PollingIntervalLowProfile = 60000; // milliseconds
const GreedyPingCount = 3;
/**
* Class that stores measurement data.
*/
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} responseReceived Client's timestamp of the response reception
*/
constructor(requestSent, requestReceived, responseSent, responseReceived) {
this.requestSent = requestSent.getTime();
this.requestReceived = requestReceived.getTime();
this.responseSent = responseSent.getTime();
this.responseReceived = responseReceived.getTime();
}
/**
* Time offset from server.
*/
getOffset () {
return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2;
}
/**
* Get round-trip delay.
*/
getDelay () {
return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived);
}
/**
* Get ping time.
*/
getPing () {
return this.getDelay() / 2;
}
}
/**
* Class that manages time syncing with server.
*/
class TimeSyncManager {
constructor() {
this.pingStop = true;
this.pollingInterval = PollingIntervalGreedy;
this.poller = null;
this.pings = 0; // number of pings
this.measurement = null; // current time sync
this.measurements = [];
this.startPing();
}
/**
* Gets status of time sync.
* @returns {boolean} _true_ if a measurement has been done, _false_ otherwise.
*/
isReady() {
return !!this.measurement;
}
/**
* Gets time offset with server.
* @returns {number} The time offset.
*/
getTimeOffset () {
return this.measurement ? this.measurement.getOffset() : 0;
}
/**
* Gets ping time to server.
* @returns {number} The ping time.
*/
getPing () {
return this.measurement ? this.measurement.getPing() : 0;
}
/**
* Updates time offset between server and client.
* @param {Measurement} measurement The new measurement.
*/
updateTimeOffset(measurement) {
this.measurements.push(measurement);
if (this.measurements.length > NumberOfTrackedMeasurements) {
this.measurements.shift();
}
// 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.
*/
requestPing() {
if (!this.poller) {
this.poller = setTimeout(() => {
this.poller = null;
const apiClient = connectionManager.currentApiClient();
const requestSent = new Date();
apiClient.getServerTime().then((response) => {
const responseReceived = new Date();
response.json().then((data) => {
const requestReceived = new Date(data.RequestReceptionTime);
const responseSent = new Date(data.ResponseTransmissionTime);
const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived);
this.updateTimeOffset(measurement);
// Avoid overloading server
if (this.pings >= GreedyPingCount) {
this.pollingInterval = PollingIntervalLowProfile;
} else {
this.pings++;
}
events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]);
});
}).catch((error) => {
console.error(error);
events.trigger(this, 'update', [error, null, null]);
}).finally(() => {
this.requestPing();
});
}, this.pollingInterval);
}
}
/**
* Drops accumulated measurements.
*/
resetMeasurements () {
this.measurement = null;
this.measurements = [];
}
/**
* Starts the time poller.
*/
startPing() {
this.requestPing();
}
/**
* Stops the time poller.
*/
stopPing() {
if (this.poller) {
clearTimeout(this.poller);
this.poller = null;
}
}
/**
* Resets poller into greedy mode.
*/
forceUpdate() {
this.stopPing();
this.pollingInterval = PollingIntervalGreedy;
this.pings = 0;
this.startPing();
}
/**
* Converts server time to local time.
* @param {Date} server The time to convert.
* @returns {Date} Local time.
*/
serverDateToLocal(server) {
// server - local = offset
return new Date(server.getTime() - this.getTimeOffset());
}
/**
* Converts local time to server time.
* @param {Date} local The time to convert.
* @returns {Date} Server time.
*/
localDateToServer(local) {
// server - local = offset
return new Date(local.getTime() + this.getTimeOffset());
}
}
/** TimeSyncManager singleton. */
export default new TimeSyncManager();

Some files were not shown because too many files have changed in this diff Show More