mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' into es6-subtitlesettings
This commit is contained in:
commit
2a30ed6461
222 changed files with 5287 additions and 2717 deletions
|
@ -42,7 +42,8 @@ module.exports = {
|
|||
'one-var': ["error", "never"],
|
||||
'quotes': ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": false }],
|
||||
'semi': ["error"],
|
||||
'space-before-blocks': ["error"]
|
||||
'space-before-blocks': ["error"],
|
||||
"space-infix-ops": "error"
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -9,3 +9,6 @@ node_modules
|
|||
# ide
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
#log
|
||||
yarn-error.log
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
- [Thibault Nocchi](https://github.com/ThibaultNocchi)
|
||||
- [MrTimscampi](https://github.com/MrTimscampi)
|
||||
- [Sarab Singh](https://github.com/sarab97)
|
||||
- [Andrei Oanca](https://github.com/OancaAndrei)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -45,7 +45,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'
|
||||
|
|
34
package.json
34
package.json
|
@ -9,7 +9,7 @@
|
|||
"@babel/plugin-transform-modules-amd": "^7.9.6",
|
||||
"@babel/polyfill": "^7.8.7",
|
||||
"@babel/preset-env": "^7.8.6",
|
||||
"autoprefixer": "^9.7.6",
|
||||
"autoprefixer": "^9.8.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"browser-sync": "^2.26.7",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
|
@ -25,7 +25,7 @@
|
|||
"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 +42,14 @@
|
|||
"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.10.3",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"webpack-merge": "^4.2.2",
|
||||
"webpack-stream": "^5.2.1"
|
||||
},
|
||||
|
@ -57,17 +57,17 @@
|
|||
"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.12.0",
|
||||
"date-fns": "^2.14.0",
|
||||
"document-register-element": "^1.14.3",
|
||||
"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.1.2",
|
||||
"jellyfin-noto": "https://github.com/jellyfin/jellyfin-noto",
|
||||
"jquery": "^3.5.0",
|
||||
"jquery": "^3.5.1",
|
||||
"jstree": "^3.3.7",
|
||||
"libass-wasm": "https://github.com/jellyfin/JavascriptSubtitlesOctopus#4.0.0-jf-smarttv",
|
||||
"material-design-icons-iconfont": "^5.0.1",
|
||||
|
@ -76,9 +76,9 @@
|
|||
"query-string": "^6.11.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"screenfull": "^5.0.2",
|
||||
"shaka-player": "^2.5.10",
|
||||
"shaka-player": "^2.5.11",
|
||||
"sortablejs": "^1.10.2",
|
||||
"swiper": "^5.3.7",
|
||||
"swiper": "^5.4.1",
|
||||
"webcomponents.js": "^0.7.24",
|
||||
"whatwg-fetch": "^3.0.0"
|
||||
},
|
||||
|
@ -95,18 +95,26 @@
|
|||
"src/components/subtitlesettings/subtitleappearancehelper.js",
|
||||
"src/components/settingshelper.js",
|
||||
"src/controllers/user/subtitles.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/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/scripts/keyboardNavigation.js",
|
||||
"src/scripts/settings/appSettings.js",
|
||||
"src/scripts/settings/userSettings.js",
|
||||
"src/scripts/settings/webSettings.js"
|
||||
|
|
|
@ -5,8 +5,10 @@ const cssnano = require('cssnano');
|
|||
|
||||
const config = () => ({
|
||||
plugins: [
|
||||
// Explicitly specify browserslist to override ones from node_modules
|
||||
// For example, Swiper has it in its package.json
|
||||
postcssPresetEnv({browsers: packageConfig.browserslist}),
|
||||
autoprefixer(),
|
||||
autoprefixer({overrideBrowserslist: packageConfig.browserslist}),
|
||||
cssnano()
|
||||
]
|
||||
});
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h1 class="sectionTitle pluginName"></h1>
|
||||
<a is="emby-linkbutton" class="raised button-alt headerHelpButton" target="_blank" href="https://docs.jellyfin.org/general/server/plugins/index.html">${Help}</a>
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://docs.jellyfin.org/general/server/plugins/index.html">${Help}</a>
|
||||
</div>
|
||||
|
||||
<p id="overview" style="font-style: italic;"></p>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<form class="addServerForm" style="margin: 0 auto;">
|
||||
<h1>${HeaderConnectToServer}</h1>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="text" id="txtServerHost" required="required" label="${LabelServerHost}" autocomplete="off" spellcheck="false" autocapitalize="none" autocorrect="off" />
|
||||
<input is="emby-input" type="url" id="txtServerHost" required="required" label="${LabelServerHost}"/>
|
||||
<div class="fieldDescription">${LabelServerHostHelp}</div>
|
||||
</div>
|
||||
<br />
|
||||
|
|
|
@ -4,18 +4,19 @@
|
|||
<div class="detailSectionHeader">
|
||||
<h2 style="margin:.6em 0;vertical-align:middle;display:inline-block;">${HeaderApiKeys}</h2>
|
||||
<button is="emby-button" type="button" class="fab btnNewKey submit" style="margin-left:1em;" title="${ButtonAdd}">
|
||||
<span class="material-icons add"></span>
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p>${HeaderApiKeysHelp}</p>
|
||||
<br />
|
||||
<table class="tblApiKeys detailTable">
|
||||
<caption class="clipForScreenReader">${ApiKeysCaption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="detailTableHeaderCell"></th>
|
||||
<th class="detailTableHeaderCell">${HeaderApiKey}</th>
|
||||
<th class="detailTableHeaderCell">${HeaderApp}</th>
|
||||
<th class="detailTableHeaderCell">${HeaderDateIssued}</th>
|
||||
<th scope="col" class="detailTableHeaderCell"></th>
|
||||
<th scope="col" class="detailTableHeaderCell">${HeaderApiKey}</th>
|
||||
<th scope="col" class="detailTableHeaderCell">${HeaderApp}</th>
|
||||
<th scope="col" class="detailTableHeaderCell">${HeaderDateIssued}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="resultBody"></tbody>
|
||||
|
|
BIN
src/assets/audio/silence.mp3
Normal file
BIN
src/assets/audio/silence.mp3
Normal file
Binary file not shown.
|
@ -5,6 +5,17 @@ html {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.clipForScreenReader {
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
/* Fix font ligatures on older WebOS versions */
|
||||
-webkit-font-feature-settings: "liga";
|
||||
|
@ -109,3 +120,11 @@ div[data-role=page] {
|
|||
.headroom--unpinned {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.force-scroll {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.hide-scroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
opacity: 0;
|
||||
}
|
||||
|
||||
.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton) {
|
||||
.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton):not(.headerSyncButton) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -1,6 +1,6 @@
|
|||
<div class="formDialogHeader">
|
||||
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1">
|
||||
<span class="material-icons arrow_back"></span>
|
||||
<button is="paper-icon-button-light" class="btnCancel autoSize" title="${LabelPrevious}" tabindex="-1">
|
||||
<span class="material-icons arrow_back" aria-hidden="true"></span>
|
||||
</button>
|
||||
<h3 class="formDialogHeaderTitle">
|
||||
${HeaderAccessSchedule}
|
345
src/components/actionSheet/actionSheet.js
Normal file
345
src/components/actionSheet/actionSheet.js
Normal file
|
@ -0,0 +1,345 @@
|
|||
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;
|
||||
}
|
||||
|
||||
let box;
|
||||
for (let elem of elems) {
|
||||
// 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.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
|
||||
};
|
|
@ -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
|
||||
};
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
define(['browser', 'css!./appfooter'], function (browser) {
|
||||
define(['browser', 'css!./appFooter'], function (browser) {
|
||||
'use strict';
|
||||
|
||||
function render(options) {
|
|
@ -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 = {
|
||||
|
|
|
@ -359,7 +359,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) {
|
||||
|
|
|
@ -306,6 +306,10 @@ button::-moz-focus-inner {
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog .cardText {
|
||||
text-overflow: initial;
|
||||
}
|
||||
|
||||
.cardText-secondary {
|
||||
font-size: 86%;
|
||||
}
|
||||
|
|
|
@ -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>';
|
||||
|
|
|
@ -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>';
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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
|
||||
};
|
||||
});
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -269,7 +269,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) {
|
|
@ -56,7 +56,7 @@
|
|||
<div class="fieldDescription">
|
||||
<div>${LabelDisplayLanguageHelp}</div>
|
||||
<div class="learnHowToContributeContainer hide" style="margin-top: .25em;">
|
||||
<a is="emby-linkbutton" class="button-link" href="https://github.com/jellyfin/jellyfin" target="_blank">${LearnHowYouCanContribute}</a>
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="https://github.com/jellyfin/jellyfin" target="_blank">${LearnHowYouCanContribute}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<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 autoSize" title="${LabelPrevious}" tabindex="-1">
|
||||
<span class="material-icons arrow_back" aria-hidden="true"></span>
|
||||
</button>
|
||||
<h3 class="formDialogHeaderTitle">
|
||||
${Settings}
|
||||
</h3>
|
||||
|
|
|
@ -9,8 +9,8 @@
|
|||
|
||||
<div class="guide-headerTimeslots">
|
||||
<div class="guide-channelTimeslotHeader">
|
||||
<button is="paper-icon-button-light" type="button" class="btnGuideViewSettings">
|
||||
<span class="material-icons btnGuideViewSettingsIcon more_horiz"></span>
|
||||
<button is="paper-icon-button-light" type="button" class="btnGuideViewSettings" title="${ButtonMore}">
|
||||
<span class="material-icons btnGuideViewSettingsIcon more_vert" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="timeslotHeaders scrollX guideScroller"></div>
|
||||
|
@ -29,10 +29,10 @@
|
|||
</div>
|
||||
|
||||
<div class="guideOptions hide">
|
||||
<button is="paper-icon-button-light" type="button" class="btnPreviousPage">
|
||||
<span class="material-icons arrow_back"></span>
|
||||
<button is="paper-icon-button-light" type="button" class="btnPreviousPage" title="${LabelPrevious}">
|
||||
<span class="material-icons arrow_back" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button is="paper-icon-button-light" type="button" class="btnNextPage">
|
||||
<span class="material-icons arrow_forward"></span>
|
||||
<button is="paper-icon-button-light" type="button" class="btnNextPage" title="${LabelNext}">
|
||||
<span class="material-icons arrow_forward" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -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));
|
|
@ -136,7 +136,10 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
|
|||
|
||||
requireHlsPlayer(function () {
|
||||
var hls = new Hls({
|
||||
manifestLoadingTimeOut: 20000
|
||||
manifestLoadingTimeOut: 20000,
|
||||
xhrSetup: function(xhr, url) {
|
||||
xhr.withCredentials = true;
|
||||
}
|
||||
//appendErrorMaxRetry: 6,
|
||||
//debug: true
|
||||
});
|
||||
|
@ -155,6 +158,9 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
|
|||
|
||||
elem.autoplay = true;
|
||||
|
||||
// Safari will not send cookies without this
|
||||
elem.crossOrigin = 'use-credentials';
|
||||
|
||||
return htmlMediaHelper.applySrc(elem, val, options).then(function () {
|
||||
|
||||
self._currentSrc = val;
|
||||
|
@ -171,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) {
|
||||
|
@ -180,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) {
|
||||
|
@ -294,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;
|
||||
|
@ -450,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) {
|
||||
|
@ -493,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;
|
||||
});
|
|
@ -194,7 +194,7 @@ define(['appSettings', 'browser', 'events'], function (appSettings, browser, eve
|
|||
}
|
||||
};
|
||||
events.map(function (name) {
|
||||
element.addEventListener(name, onMediaChange);
|
||||
return element.addEventListener(name, onMediaChange);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -106,10 +106,18 @@ 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) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var duration = 240;
|
||||
elem.style.animation = 'htmlvideoplayer-zoomin ' + duration + 'ms ease-in normal';
|
||||
hidePrePlaybackPage();
|
||||
dom.addEventListener(elem, dom.whichAnimationEvent(), resolve, {
|
||||
once: true
|
||||
});
|
||||
|
@ -290,7 +298,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
|
||||
return createMediaElement(options).then(function (elem) {
|
||||
|
||||
return updateVideoUrl(options, options.mediaSource).then(function () {
|
||||
return updateVideoUrl(options).then(function () {
|
||||
return setCurrentSrc(elem, options);
|
||||
});
|
||||
});
|
||||
|
@ -330,7 +338,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
requireHlsPlayer(function () {
|
||||
|
||||
var hls = new Hls({
|
||||
manifestLoadingTimeOut: 20000
|
||||
manifestLoadingTimeOut: 20000,
|
||||
xhrSetup: function(xhr, xhr_url) {
|
||||
xhr.withCredentials = true;
|
||||
}
|
||||
//appendErrorMaxRetry: 6,
|
||||
//debug: true
|
||||
});
|
||||
|
@ -551,6 +562,9 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
|
||||
elem.autoplay = true;
|
||||
|
||||
// Safari will not send cookies without this
|
||||
elem.crossOrigin = 'use-credentials';
|
||||
|
||||
return htmlMediaHelper.applySrc(elem, val, options).then(function () {
|
||||
|
||||
self._currentSrc = val;
|
||||
|
@ -785,6 +799,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);
|
||||
}
|
||||
|
@ -836,7 +851,6 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
function onNavigatedToOsd() {
|
||||
var dlg = videoDialog;
|
||||
if (dlg) {
|
||||
dlg.classList.remove('videoPlayerContainer-withBackdrop');
|
||||
dlg.classList.remove('videoPlayerContainer-onTop');
|
||||
|
||||
onStartedAndNavigatedToOsd();
|
||||
|
@ -873,7 +887,6 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
|
||||
} else {
|
||||
appRouter.setTransparency('backdrop');
|
||||
videoDialog.classList.remove('videoPlayerContainer-withBackdrop');
|
||||
videoDialog.classList.remove('videoPlayerContainer-onTop');
|
||||
|
||||
onStartedAndNavigatedToOsd();
|
||||
|
@ -915,6 +928,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 || '') : '';
|
||||
|
@ -1290,12 +1307,6 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
|
||||
function createMediaElement(options) {
|
||||
|
||||
if (browser.tv || browser.iOS || browser.mobile) {
|
||||
// too slow
|
||||
// also on iOS, the backdrop image doesn't look right
|
||||
// on android mobile, it works, but can be slow to have the video surface fully cover the backdrop
|
||||
options.backdropUrl = null;
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
||||
var dlg = document.querySelector('.videoPlayerContainer');
|
||||
|
@ -1310,11 +1321,6 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
|
||||
dlg.classList.add('videoPlayerContainer');
|
||||
|
||||
if (options.backdropUrl) {
|
||||
dlg.classList.add('videoPlayerContainer-withBackdrop');
|
||||
dlg.style.backgroundImage = "url('" + options.backdropUrl + "')";
|
||||
}
|
||||
|
||||
if (options.fullscreen) {
|
||||
dlg.classList.add('videoPlayerContainer-onTop');
|
||||
}
|
||||
|
@ -1348,6 +1354,10 @@ 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;
|
||||
}
|
||||
|
||||
document.body.insertBefore(dlg, document.body.firstChild);
|
||||
videoDialog = dlg;
|
||||
|
@ -1367,15 +1377,11 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
resolve(videoElement);
|
||||
});
|
||||
} else {
|
||||
hidePrePlaybackPage();
|
||||
resolve(videoElement);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (options.backdropUrl) {
|
||||
dlg.classList.add('videoPlayerContainer-withBackdrop');
|
||||
dlg.style.backgroundImage = "url('" + options.backdropUrl + "')";
|
||||
}
|
||||
|
||||
resolve(dlg.querySelector('video'));
|
||||
}
|
||||
});
|
||||
|
@ -1436,6 +1442,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');
|
||||
|
||||
|
@ -1656,6 +1666,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) {
|
|
@ -6,20 +6,9 @@
|
|||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.videoPlayerContainer:not(.videoPlayerContainer-withBackdrop) {
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
.videoPlayerContainer-withBackdrop {
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.videoPlayerContainer-onTop {
|
||||
z-index: 1000;
|
||||
}
|
|
@ -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);
|
|
@ -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;">
|
||||
|
|
@ -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
|
||||
});
|
|
@ -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);
|
|
@ -5,7 +5,7 @@
|
|||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="formDialogContent">
|
||||
<div class="formDialogContent smoothScrollY">
|
||||
<div class="dialogContentInner">
|
||||
|
||||
<form class="uploadItemImageForm" style="max-width: 100%;">
|
|
@ -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);
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="formDialogContent">
|
||||
<div class="formDialogContent smoothScrollY">
|
||||
<div class="dialogContentInner">
|
||||
|
||||
<div id="imagesContainer">
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
});
|
|
@ -1,12 +1,13 @@
|
|||
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') {
|
||||
export function enableProgressIndicator(item) {
|
||||
if (item.MediaType === 'Video' && item.Type !== 'TvChannel') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.Type === 'AudioBook' || item.Type === 'AudioPodcast') {
|
||||
return true;
|
||||
|
@ -15,26 +16,22 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
return false;
|
||||
}
|
||||
|
||||
function getProgressHtml(pct, options) {
|
||||
var containerClass = 'itemProgressBar';
|
||||
if (options) {
|
||||
if (options.containerClass) {
|
||||
export function getProgressHtml(pct, options) {
|
||||
let containerClass = 'itemProgressBar';
|
||||
if (options && options.containerClass) {
|
||||
containerClass += ' ' + options.containerClass;
|
||||
}
|
||||
}
|
||||
|
||||
return '<div class="' + containerClass + '"><div class="itemProgressBarForeground" style="width:' + pct + '%;"></div></div>';
|
||||
}
|
||||
|
||||
function getAutoTimeProgressHtml(pct, options, isRecording, start, end) {
|
||||
var containerClass = 'itemProgressBar';
|
||||
if (options) {
|
||||
if (options.containerClass) {
|
||||
let containerClass = 'itemProgressBar';
|
||||
if (options && options.containerClass) {
|
||||
containerClass += ' ' + options.containerClass;
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundClass = 'itemProgressBarForeground';
|
||||
let foregroundClass = 'itemProgressBarForeground';
|
||||
if (isRecording) {
|
||||
foregroundClass += ' itemProgressBarForeground-recording';
|
||||
}
|
||||
|
@ -42,10 +39,11 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
return '<div is="emby-progressbar" data-automode="time" data-starttime="' + start + '" data-endtime="' + end + '" class="' + containerClass + '"><div class="' + foregroundClass + '" style="width:' + pct + '%;"></div></div>';
|
||||
}
|
||||
|
||||
function getProgressBarHtml(item, options) {
|
||||
var pct;
|
||||
export function getProgressBarHtml(item, options) {
|
||||
let pct;
|
||||
if (enableProgressIndicator(item) && item.Type !== 'Recording') {
|
||||
var userData = options ? (options.userData || item.UserData) : item.UserData;
|
||||
const userData = options && options.userData ? options.userData : item.UserData;
|
||||
|
||||
if (userData) {
|
||||
pct = userData.PlayedPercentage;
|
||||
if (pct && pct < 100) {
|
||||
|
@ -55,8 +53,8 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
}
|
||||
|
||||
if ((item.Type === 'Program' || item.Type === 'Timer' || item.Type === 'Recording') && item.StartDate && item.EndDate) {
|
||||
var startDate = 0;
|
||||
var endDate = 1;
|
||||
let startDate = 0;
|
||||
let endDate = 1;
|
||||
|
||||
try {
|
||||
startDate = datetime.parseISO8601Date(item.StartDate).getTime();
|
||||
|
@ -65,12 +63,12 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
console.error(err);
|
||||
}
|
||||
|
||||
var now = new Date().getTime();
|
||||
var total = endDate - startDate;
|
||||
const now = new Date().getTime();
|
||||
const total = endDate - startDate;
|
||||
pct = 100 * ((now - startDate) / total);
|
||||
|
||||
if (pct > 0 && pct < 100) {
|
||||
var isRecording = item.Type === 'Timer' || item.Type === 'Recording' || item.TimerId;
|
||||
const isRecording = item.Type === 'Timer' || item.Type === 'Recording' || item.TimerId;
|
||||
return getAutoTimeProgressHtml(pct, options, isRecording, startDate, endDate);
|
||||
}
|
||||
}
|
||||
|
@ -78,13 +76,13 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
return '';
|
||||
}
|
||||
|
||||
function enablePlayedIndicator(item) {
|
||||
export function enablePlayedIndicator(item) {
|
||||
return itemHelper.canMarkPlayed(item);
|
||||
}
|
||||
|
||||
function getPlayedIndicator(item) {
|
||||
export function getPlayedIndicatorHtml(item) {
|
||||
if (enablePlayedIndicator(item)) {
|
||||
var userData = item.UserData || {};
|
||||
let userData = item.UserData || {};
|
||||
if (userData.UnplayedItemCount) {
|
||||
return '<div class="countIndicator indicator">' + userData.UnplayedItemCount + '</div>';
|
||||
}
|
||||
|
@ -97,25 +95,18 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
return '';
|
||||
}
|
||||
|
||||
function getCountIndicatorHtml(count) {
|
||||
return '<div class="countIndicator indicator">' + count + '</div>';
|
||||
}
|
||||
|
||||
function getChildCountIndicatorHtml(item, options) {
|
||||
var minCount = 0;
|
||||
if (options) {
|
||||
minCount = options.minCount || minCount;
|
||||
}
|
||||
export function getChildCountIndicatorHtml(item, options) {
|
||||
const minCount = options && options.minCount ? options.minCount : 0;
|
||||
|
||||
if (item.ChildCount && item.ChildCount > minCount) {
|
||||
return getCountIndicatorHtml(item.ChildCount);
|
||||
return '<div class="countIndicator indicator">' + item.ChildCount + '</div>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getTimerIndicator(item) {
|
||||
var status;
|
||||
export function getTimerIndicator(item) {
|
||||
let status;
|
||||
|
||||
if (item.Type === 'SeriesTimer') {
|
||||
return '<span class="material-icons timerIndicator indicatorIcon fiber_smart_record"></span>';
|
||||
|
@ -138,7 +129,7 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
return '<span class="material-icons timerIndicator indicatorIcon fiber_manual_record"></span>';
|
||||
}
|
||||
|
||||
function getSyncIndicator(item) {
|
||||
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) {
|
||||
|
@ -148,28 +139,23 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
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>';
|
||||
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>' : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMissingIndicator(item) {
|
||||
export function getMissingIndicator(item) {
|
||||
if (item.Type === 'Episode' && item.LocationType === 'Virtual') {
|
||||
if (item.PremiereDate) {
|
||||
try {
|
||||
var premiereDate = datetime.parseISO8601Date(item.PremiereDate).getTime();
|
||||
const premiereDate = datetime.parseISO8601Date(item.PremiereDate).getTime();
|
||||
if (premiereDate > new Date().getTime()) {
|
||||
return '<div class="unairedIndicator">Unaired</div>';
|
||||
}
|
||||
|
@ -183,10 +169,10 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
return '';
|
||||
}
|
||||
|
||||
return {
|
||||
export default {
|
||||
getProgressHtml: getProgressHtml,
|
||||
getProgressBarHtml: getProgressBarHtml,
|
||||
getPlayedIndicatorHtml: getPlayedIndicator,
|
||||
getPlayedIndicatorHtml: getPlayedIndicatorHtml,
|
||||
getChildCountIndicatorHtml: getChildCountIndicatorHtml,
|
||||
enableProgressIndicator: enableProgressIndicator,
|
||||
getTimerIndicator: getTimerIndicator,
|
||||
|
@ -195,4 +181,3 @@ define(['datetime', 'itemHelper', 'emby-progressbar', 'css!./indicators.css', 'm
|
|||
getTypeIndicator: getTypeIndicator,
|
||||
getMissingIndicator: getMissingIndicator
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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':
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -109,8 +109,8 @@ define(['globalize', 'dom', 'emby-checkbox', 'emby-select', 'emby-input'], funct
|
|||
html += '<div class="metadataFetcher" data-type="' + availableTypeOptions.Type + '">';
|
||||
html += '<h3 class="checkboxListLabel">' + globalize.translate('LabelTypeMetadataDownloaders', globalize.translate(availableTypeOptions.Type)) + '</h3>';
|
||||
html += '<div class="checkboxList paperList checkboxList-paperList">';
|
||||
for (var i = 0; i < plugins.length; i++) {
|
||||
var plugin = plugins[i];
|
||||
|
||||
plugins.forEach((plugin, index) => {
|
||||
html += '<div class="listItem metadataFetcherItem sortableOption" data-pluginname="' + plugin.Name + '">';
|
||||
var isChecked = libraryOptionsForType.MetadataFetchers ? -1 !== libraryOptionsForType.MetadataFetchers.indexOf(plugin.Name) : plugin.DefaultEnabled;
|
||||
var checkedHtml = isChecked ? ' checked="checked"' : '';
|
||||
|
@ -120,8 +120,9 @@ define(['globalize', 'dom', 'emby-checkbox', 'emby-select', 'emby-input'], funct
|
|||
html += plugin.Name;
|
||||
html += '</h3>';
|
||||
html += '</div>';
|
||||
i > 0 ? html += '<button type="button" is="paper-icon-button-light" title="' + globalize.translate('ButtonUp') + '" class="btnSortableMoveUp btnSortable" data-pluginindex="' + i + '"><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="' + i + '"><span class="material-icons keyboard_arrow_down"></span></button>'), 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>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
html += '<div class="fieldDescription">' + globalize.translate('LabelMetadataDownloadersHelp') + '</div>';
|
||||
html += '</div>';
|
||||
|
@ -292,11 +293,15 @@ define(['globalize', 'dom', 'emby-checkbox', 'emby-select', 'emby-input'], funct
|
|||
function showImageOptionsForType(type) {
|
||||
require(['imageoptionseditor'], function(ImageOptionsEditor) {
|
||||
var typeOptions = getTypeOptions(currentLibraryOptions, type);
|
||||
typeOptions || (typeOptions = {
|
||||
if (!typeOptions) {
|
||||
typeOptions = {
|
||||
Type: type
|
||||
}, currentLibraryOptions.TypeOptions.push(typeOptions));
|
||||
};
|
||||
currentLibraryOptions.TypeOptions.push(typeOptions);
|
||||
}
|
||||
var availableOptions = getTypeOptions(currentAvailableOptions || {}, type);
|
||||
(new ImageOptionsEditor).show(type, typeOptions, availableOptions);
|
||||
var imageOptionsEditor = new ImageOptionsEditor();
|
||||
imageOptionsEditor.show(type, typeOptions, availableOptions);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -315,10 +320,16 @@ define(['globalize', 'dom', 'emby-checkbox', 'emby-select', 'emby-input'], funct
|
|||
var list = dom.parentWithClass(li, 'paperList');
|
||||
if (btnSortable.classList.contains('btnSortableMoveDown')) {
|
||||
var next = li.nextSibling;
|
||||
next && (li.parentNode.removeChild(li), next.parentNode.insertBefore(li, next.nextSibling));
|
||||
if (next) {
|
||||
li.parentNode.removeChild(li);
|
||||
next.parentNode.insertBefore(li, next.nextSibling);
|
||||
}
|
||||
} else {
|
||||
var prev = li.previousSibling;
|
||||
prev && (li.parentNode.removeChild(li), prev.parentNode.insertBefore(li, prev));
|
||||
if (prev) {
|
||||
li.parentNode.removeChild(li);
|
||||
prev.parentNode.insertBefore(li, prev);
|
||||
}
|
||||
}
|
||||
Array.prototype.forEach.call(list.querySelectorAll('.sortableOption'), adjustSortableListElement);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -490,26 +490,26 @@ define(['datetime', 'globalize', 'appRouter', 'itemHelper', 'indicators', 'mater
|
|||
if (i.IsInterlaced) {
|
||||
return '1440i';
|
||||
}
|
||||
return '1440P';
|
||||
return '1440p';
|
||||
}
|
||||
if (width >= 1800 || height >= 1000) {
|
||||
if (i.IsInterlaced) {
|
||||
return '1080i';
|
||||
}
|
||||
return '1080P';
|
||||
return '1080p';
|
||||
}
|
||||
if (width >= 1200 || height >= 700) {
|
||||
if (i.IsInterlaced) {
|
||||
return '720i';
|
||||
}
|
||||
return '720P';
|
||||
return '720p';
|
||||
}
|
||||
if (width >= 700 || height >= 400) {
|
||||
|
||||
if (i.IsInterlaced) {
|
||||
return '480i';
|
||||
}
|
||||
return '480P';
|
||||
return '480p';
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
@ -791,11 +750,7 @@ define(['itemHelper', 'dom', 'layoutManager', 'dialogHelper', 'datetime', 'loadi
|
|||
return a.Name;
|
||||
}).join(';');
|
||||
|
||||
if (item.Type === 'Series') {
|
||||
context.querySelector('#selectDisplayOrder').value = item.DisplayOrder || '';
|
||||
} else {
|
||||
context.querySelector('#selectDisplayOrder').value = item.DisplayOrder || '';
|
||||
}
|
||||
|
||||
context.querySelector('#txtArtist').value = (item.ArtistItems || []).map(function (a) {
|
||||
return a.Name;
|
||||
|
@ -1071,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,
|
||||
|
@ -1081,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);
|
||||
|
@ -1128,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');
|
||||
|
|
@ -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,12 +57,14 @@
|
|||
<div id="fldAlbum" class="hide inputContainer">
|
||||
<input is="emby-input" id="txtAlbum" type="text" label="${LabelAlbum}" />
|
||||
</div>
|
||||
<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}" />
|
||||
</div>
|
||||
|
@ -129,12 +131,15 @@
|
|||
<div id="fldSeriesRuntime" class="inputContainer hide">
|
||||
<input is="emby-input" id="txtSeriesRuntime" type="number" label="${LabelRuntimeMinutes}" />
|
||||
</div>
|
||||
<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 class="inlineForm">
|
||||
<div id="fldOriginalAspectRatio" class="inputContainer hide">
|
||||
<input is="emby-input" id="txtOriginalAspectRatio" type="text" label="${LabelOriginalAspectRatio}" />
|
||||
</div>
|
||||
|
@ -148,6 +153,7 @@
|
|||
<option value="MVC">MVC</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fldDisplayOrder" class="fldDisplaySetting selectContainer hide">
|
||||
<select is="emby-select" id="selectDisplayOrder" label="${LabelDisplayOrder}"></select>
|
||||
|
@ -160,6 +166,7 @@
|
|||
<h2>
|
||||
${HeaderSpecialEpisodeInfo}
|
||||
</h2>
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" id="txtAirsBeforeSeason" type="number" pattern="[0-9]*" label="${LabelAirsBeforeSeason}" />
|
||||
</div>
|
||||
|
@ -170,6 +177,7 @@
|
|||
<input is="emby-input" id="txtAirsBeforeEpisode" type="number" pattern="[0-9]*" label="${LabelAirsBeforeEpisode}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detailSection externalIdsSection hide">
|
||||
<h2>
|
||||
|
@ -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>
|
||||
|
|
@ -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);
|
|
@ -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;
|
|
@ -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');
|
|
@ -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) {
|
||||
|
@ -309,13 +314,11 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
|
|||
if (codecProfile.Type === 'Audio') {
|
||||
(codecProfile.Conditions || []).map(function (condition) {
|
||||
if (condition.Condition === 'LessThanEqual' && condition.Property === 'AudioBitDepth') {
|
||||
maxAudioBitDepth = condition.Value;
|
||||
}
|
||||
if (condition.Condition === 'LessThanEqual' && condition.Property === 'AudioSampleRate') {
|
||||
maxAudioSampleRate = condition.Value;
|
||||
}
|
||||
if (condition.Condition === 'LessThanEqual' && condition.Property === 'AudioBitrate') {
|
||||
maxAudioBitrate = condition.Value;
|
||||
return maxAudioBitDepth = condition.Value;
|
||||
} else if (condition.Condition === 'LessThanEqual' && condition.Property === 'AudioSampleRate') {
|
||||
return maxAudioSampleRate = condition.Value;
|
||||
} else if (condition.Condition === 'LessThanEqual' && condition.Property === 'AudioBitrate') {
|
||||
return maxAudioBitrate = condition.Value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -3777,6 +3780,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;
|
||||
|
@ -3887,6 +3904,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
|
|||
if (player.supports('SetAspectRatio')) {
|
||||
list.push('SetAspectRatio');
|
||||
}
|
||||
if (player.supports('PlaybackRate')) {
|
||||
list.push('PlaybackRate');
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
@ -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>
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,23 +1,20 @@
|
|||
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 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 playableItemId = itemType === 'Program' ? channelId : itemId;
|
||||
var playableItemId = item.Type === 'Program' ? item.ChannelId : item.Id;
|
||||
|
||||
if (!resumePositionTicks || isFolder) {
|
||||
if (!resumePositionTicks || item.IsFolder) {
|
||||
playbackManager.play({
|
||||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
serverId: item.ServerId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -45,14 +42,14 @@ define(['actionsheet', 'datetime', 'playbackManager', 'globalize', 'appSettings'
|
|||
case 'play':
|
||||
playbackManager.play({
|
||||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
serverId: item.ServerId
|
||||
});
|
||||
break;
|
||||
case 'resume':
|
||||
playbackManager.play({
|
||||
ids: [playableItemId],
|
||||
startPositionTicks: resumePositionTicks,
|
||||
serverId: serverId
|
||||
serverId: item.ServerId
|
||||
});
|
||||
break;
|
||||
case 'queue':
|
||||
|
@ -69,7 +66,6 @@ define(['actionsheet', 'datetime', 'playbackManager', 'globalize', 'appSettings'
|
|||
});
|
||||
}
|
||||
|
||||
return {
|
||||
export default {
|
||||
show: show
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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,48 +25,11 @@ 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) {
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
function registerPlugin(plugin) {
|
||||
instance.register(plugin);
|
||||
|
||||
if (plugin.getRoutes) {
|
||||
|
@ -78,15 +41,62 @@ define(['events'], function (events) {
|
|||
if (plugin.type === 'skin') {
|
||||
|
||||
// translations won't be loaded for skins until needed
|
||||
resolve(plugin);
|
||||
return Promise.resolve(plugin);
|
||||
} else {
|
||||
|
||||
loadStrings(plugin, globalize).then(function () {
|
||||
return new Promise((resolve, reject) => {
|
||||
loadStrings(plugin)
|
||||
.then(function () {
|
||||
resolve(plugin);
|
||||
}, reject);
|
||||
})
|
||||
.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 = 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
|
||||
});
|
||||
|
||||
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
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
@ -137,6 +137,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 +157,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);
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -232,11 +232,7 @@ define(['dialogHelper', 'require', 'layoutManager', 'globalize', 'userSettings',
|
|||
html += '</div>';
|
||||
}
|
||||
html += '<h2>' + provider + '</h2>';
|
||||
if (layoutManager.tv) {
|
||||
html += '<div>';
|
||||
} else {
|
||||
html += '<div>';
|
||||
}
|
||||
lastProvider = provider;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,7 @@
|
|||
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1"><span class="material-icons arrow_back"></span></button>
|
||||
<h3 class="formDialogHeaderTitle">${Subtitles}</h3>
|
||||
|
||||
<a is="emby-linkbutton" data-autohide="true" class="button-link btnHelp flex align-items-center" href="https://docs.jellyfin.org/general/server/media/subtitles.html" target="_blank" style="margin-left:auto;margin-right:.5em;padding:.25em;" title="${Help}"><span class="material-icons info"></span><span style="margin-left:.25em;">${Help}</span></a>
|
||||
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" data-autohide="true" class="button-link btnHelp flex align-items-center" href="https://docs.jellyfin.org/general/server/media/subtitles.html" target="_blank" style="margin-left:auto;margin-right:.5em;padding:.25em;" title="${Help}"><span class="material-icons info"></span><span style="margin-left:.25em;">${Help}</span></a>
|
||||
</div>
|
||||
<div class="formDialogContent smoothScrollY">
|
||||
<div class="dialogContentInner dialog-content-centered">
|
||||
|
|
189
src/components/syncplay/groupSelectionMenu.js
Normal file
189
src/components/syncplay/groupSelectionMenu.js
Normal 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')
|
||||
});
|
||||
});
|
||||
}
|
51
src/components/syncplay/playbackPermissionManager.js
Normal file
51
src/components/syncplay/playbackPermissionManager.js
Normal 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();
|
839
src/components/syncplay/syncPlayManager.js
Normal file
839
src/components/syncplay/syncPlayManager.js
Normal file
|
@ -0,0 +1,839 @@
|
|||
/**
|
||||
* 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);
|
||||
this.playbackRateSupported = player.supports('PlaybackRate');
|
||||
|
||||
// Save player current PlaybackRate value
|
||||
if (this.playbackRateSupported) {
|
||||
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();
|
207
src/components/syncplay/timeSyncManager.js
Normal file
207
src/components/syncplay/timeSyncManager.js
Normal 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
Loading…
Add table
Add a link
Reference in a new issue