mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge remote-tracking branch 'upstream/master' into blurhash
This commit is contained in:
commit
2a0f971e76
53 changed files with 3192 additions and 616 deletions
288
src/components/bookPlayer/plugin.js
Normal file
288
src/components/bookPlayer/plugin.js
Normal file
|
@ -0,0 +1,288 @@
|
|||
import connectionManager from 'connectionManager';
|
||||
import loading from 'loading';
|
||||
import keyboardnavigation from 'keyboardnavigation';
|
||||
import dialogHelper from 'dialogHelper';
|
||||
import events from 'events';
|
||||
import 'css!./style';
|
||||
import 'material-icons';
|
||||
import 'paper-icon-button-light';
|
||||
|
||||
import TableOfContent from './tableOfContent';
|
||||
|
||||
export class BookPlayer {
|
||||
constructor() {
|
||||
this.name = 'Book Player';
|
||||
this.type = 'mediaplayer';
|
||||
this.id = 'bookplayer';
|
||||
this.priority = 1;
|
||||
|
||||
this.onDialogClosed = this.onDialogClosed.bind(this);
|
||||
this.openTableOfContents = this.openTableOfContents.bind(this);
|
||||
this.onWindowKeyUp = this.onWindowKeyUp.bind(this);
|
||||
}
|
||||
|
||||
play(options) {
|
||||
this._progress = 0;
|
||||
this._loaded = false;
|
||||
|
||||
loading.show();
|
||||
let elem = this.createMediaElement();
|
||||
return this.setCurrentSrc(elem, options);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.unbindEvents();
|
||||
|
||||
let elem = this._mediaElement;
|
||||
let tocElement = this._tocElement;
|
||||
let rendition = this._rendition;
|
||||
|
||||
if (elem) {
|
||||
dialogHelper.close(elem);
|
||||
this._mediaElement = null;
|
||||
}
|
||||
|
||||
if (tocElement) {
|
||||
tocElement.destroy();
|
||||
this._tocElement = null;
|
||||
}
|
||||
|
||||
if (rendition) {
|
||||
rendition.destroy();
|
||||
}
|
||||
|
||||
// Hide loader in case player was not fully loaded yet
|
||||
loading.hide();
|
||||
this._cancellationToken.shouldCancel = true;
|
||||
}
|
||||
|
||||
currentItem() {
|
||||
return this._currentItem;
|
||||
}
|
||||
|
||||
currentTime() {
|
||||
return this._progress * 1000;
|
||||
}
|
||||
|
||||
duration() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
getBufferedRanges() {
|
||||
return [{
|
||||
start: 0,
|
||||
end: 10000000
|
||||
}];
|
||||
}
|
||||
|
||||
volume() {
|
||||
return 100;
|
||||
}
|
||||
|
||||
isMuted() {
|
||||
return false;
|
||||
}
|
||||
|
||||
paused() {
|
||||
return false;
|
||||
}
|
||||
|
||||
seekable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
onWindowKeyUp(e) {
|
||||
let key = keyboardnavigation.getKeyName(e);
|
||||
let rendition = this._rendition;
|
||||
let book = rendition.book;
|
||||
|
||||
switch (key) {
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
case 'Right':
|
||||
if (this._loaded) {
|
||||
book.package.metadata.direction === 'rtl' ? rendition.prev() : rendition.next();
|
||||
}
|
||||
break;
|
||||
case 'j':
|
||||
case 'ArrowLeft':
|
||||
case 'Left':
|
||||
if (this._loaded) {
|
||||
book.package.metadata.direction === 'rtl' ? rendition.next() : rendition.prev();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
if (this._tocElement) {
|
||||
// Close table of contents on ESC if it is open
|
||||
this._tocElement.destroy();
|
||||
} else {
|
||||
// Otherwise stop the entire book player
|
||||
this.stop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onDialogClosed() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
bindMediaElementEvents() {
|
||||
let elem = this._mediaElement;
|
||||
|
||||
elem.addEventListener('close', this.onDialogClosed, {once: true});
|
||||
elem.querySelector('.btnBookplayerExit').addEventListener('click', this.onDialogClosed, {once: true});
|
||||
elem.querySelector('.btnBookplayerToc').addEventListener('click', this.openTableOfContents);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.bindMediaElementEvents();
|
||||
|
||||
document.addEventListener('keyup', this.onWindowKeyUp);
|
||||
// FIXME: I don't really get why document keyup event is not triggered when epub is in focus
|
||||
this._rendition.on('keyup', this.onWindowKeyUp);
|
||||
}
|
||||
|
||||
unbindMediaElementEvents() {
|
||||
let elem = this._mediaElement;
|
||||
|
||||
elem.removeEventListener('close', this.onDialogClosed);
|
||||
elem.querySelector('.btnBookplayerExit').removeEventListener('click', this.onDialogClosed);
|
||||
elem.querySelector('.btnBookplayerToc').removeEventListener('click', this.openTableOfContents);
|
||||
}
|
||||
|
||||
unbindEvents() {
|
||||
if (this._mediaElement) {
|
||||
this.unbindMediaElementEvents();
|
||||
}
|
||||
document.removeEventListener('keyup', this.onWindowKeyUp);
|
||||
if (this._rendition) {
|
||||
this._rendition.off('keyup', this.onWindowKeyUp);
|
||||
}
|
||||
}
|
||||
|
||||
openTableOfContents() {
|
||||
if (this._loaded) {
|
||||
this._tocElement = new TableOfContent(this);
|
||||
}
|
||||
}
|
||||
|
||||
createMediaElement() {
|
||||
let elem = this._mediaElement;
|
||||
|
||||
if (elem) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
elem = document.getElementById('bookPlayer');
|
||||
|
||||
if (!elem) {
|
||||
elem = dialogHelper.createDialog({
|
||||
exitAnimationDuration: 400,
|
||||
size: 'fullscreen',
|
||||
autoFocus: false,
|
||||
scrollY: false,
|
||||
exitAnimation: 'fadeout',
|
||||
removeOnClose: true
|
||||
});
|
||||
elem.id = 'bookPlayer';
|
||||
|
||||
let html = '';
|
||||
html += '<div class="topRightActionButtons">';
|
||||
html += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerExit hide-mouse-idle-tv" tabindex="-1"><i class="material-icons bookplayerButtonIcon close"></i></button>';
|
||||
html += '</div>';
|
||||
html += '<div class="topLeftActionButtons">';
|
||||
html += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerToc hide-mouse-idle-tv" tabindex="-1"><i class="material-icons bookplayerButtonIcon toc"></i></button>';
|
||||
html += '</div>';
|
||||
|
||||
elem.innerHTML = html;
|
||||
|
||||
dialogHelper.open(elem);
|
||||
}
|
||||
|
||||
this._mediaElement = elem;
|
||||
|
||||
return elem;
|
||||
}
|
||||
|
||||
setCurrentSrc(elem, options) {
|
||||
let item = options.items[0];
|
||||
this._currentItem = item;
|
||||
this.streamInfo = {
|
||||
started: true,
|
||||
ended: false,
|
||||
mediaSource: {
|
||||
Id: item.Id
|
||||
}
|
||||
};
|
||||
if (!item.Path.endsWith('.epub')) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let errorDialog = dialogHelper.createDialog({
|
||||
size: 'small',
|
||||
autoFocus: false,
|
||||
removeOnClose: true
|
||||
});
|
||||
|
||||
errorDialog.innerHTML = '<h1 class="bookplayerErrorMsg">This book type is not supported yet</h1>';
|
||||
|
||||
this.stop();
|
||||
|
||||
dialogHelper.open(errorDialog);
|
||||
loading.hide();
|
||||
|
||||
return resolve();
|
||||
});
|
||||
}
|
||||
let serverId = item.ServerId;
|
||||
let apiClient = connectionManager.getApiClient(serverId);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
require(['epubjs'], (epubjs) => {
|
||||
let downloadHref = apiClient.getItemDownloadUrl(item.Id);
|
||||
let book = epubjs.default(downloadHref, {openAs: 'epub'});
|
||||
let rendition = book.renderTo(elem, {width: '100%', height: '97%'});
|
||||
|
||||
this._currentSrc = downloadHref;
|
||||
this._rendition = rendition;
|
||||
let cancellationToken = {
|
||||
shouldCancel: false
|
||||
};
|
||||
this._cancellationToken = cancellationToken;
|
||||
|
||||
return rendition.display().then(() => {
|
||||
let epubElem = document.querySelector('.epub-container');
|
||||
epubElem.style.display = 'none';
|
||||
|
||||
this.bindEvents();
|
||||
|
||||
return this._rendition.book.locations.generate(1024).then(() => {
|
||||
if (cancellationToken.shouldCancel) {
|
||||
return reject();
|
||||
}
|
||||
|
||||
this._loaded = true;
|
||||
epubElem.style.display = 'block';
|
||||
rendition.on('relocated', (locations) => {
|
||||
this._progress = book.locations.percentageFromCfi(locations.start.cfi);
|
||||
|
||||
events.trigger(this, 'timeupdate');
|
||||
});
|
||||
|
||||
loading.hide();
|
||||
|
||||
return resolve();
|
||||
});
|
||||
}, () => {
|
||||
console.error('Failed to display epub');
|
||||
return reject();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
canPlayMediaType(mediaType) {
|
||||
return (mediaType || '').toLowerCase() === 'book';
|
||||
}
|
||||
}
|
||||
|
||||
export default BookPlayer;
|
39
src/components/bookPlayer/style.css
Normal file
39
src/components/bookPlayer/style.css
Normal file
|
@ -0,0 +1,39 @@
|
|||
#bookPlayer {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topRightActionButtons {
|
||||
right: 0.5vh;
|
||||
top: 0.5vh;
|
||||
z-index: 1002;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.topLeftActionButtons {
|
||||
left: 0.5vh;
|
||||
top: 0.5vh;
|
||||
z-index: 1002;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.bookplayerButtonIcon {
|
||||
color: black;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#dialogToc {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.toc li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bookplayerErrorMsg {
|
||||
text-align: center;
|
||||
}
|
90
src/components/bookPlayer/tableOfContent.js
Normal file
90
src/components/bookPlayer/tableOfContent.js
Normal file
|
@ -0,0 +1,90 @@
|
|||
import dialogHelper from 'dialogHelper';
|
||||
|
||||
export default class TableOfContent {
|
||||
constructor(bookPlayer) {
|
||||
this._bookPlayer = bookPlayer;
|
||||
this._rendition = bookPlayer._rendition;
|
||||
|
||||
this.onDialogClosed = this.onDialogClosed.bind(this);
|
||||
|
||||
this.createMediaElement();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
let elem = this._elem;
|
||||
if (elem) {
|
||||
this.unbindEvents();
|
||||
dialogHelper.close(elem);
|
||||
}
|
||||
|
||||
this._bookPlayer._tocElement = null;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
let elem = this._elem;
|
||||
|
||||
elem.addEventListener('close', this.onDialogClosed, {once: true});
|
||||
elem.querySelector('.btnBookplayerTocClose').addEventListener('click', this.onDialogClosed, {once: true});
|
||||
}
|
||||
|
||||
unbindEvents() {
|
||||
let elem = this._elem;
|
||||
|
||||
elem.removeEventListener('close', this.onDialogClosed);
|
||||
elem.querySelector('.btnBookplayerTocClose').removeEventListener('click', this.onDialogClosed);
|
||||
}
|
||||
|
||||
onDialogClosed() {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
replaceLinks(contents, f) {
|
||||
let links = contents.querySelectorAll('a[href]');
|
||||
|
||||
links.forEach((link) => {
|
||||
let href = link.getAttribute('href');
|
||||
|
||||
link.onclick = () => {
|
||||
f(href);
|
||||
return false;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
createMediaElement() {
|
||||
let rendition = this._rendition;
|
||||
|
||||
let elem = dialogHelper.createDialog({
|
||||
size: 'small',
|
||||
autoFocus: false,
|
||||
removeOnClose: true
|
||||
});
|
||||
elem.id = 'dialogToc';
|
||||
|
||||
let tocHtml = '<div class="topRightActionButtons">';
|
||||
tocHtml += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerTocClose hide-mouse-idle-tv" tabindex="-1"><span class="material-icons bookplayerButtonIcon close"></span></button>';
|
||||
tocHtml += '</div>';
|
||||
tocHtml += '<ul class="toc">';
|
||||
rendition.book.navigation.forEach((chapter) => {
|
||||
tocHtml += '<li>';
|
||||
// Remove '../' from href
|
||||
let link = chapter.href.startsWith('../') ? chapter.href.substr(3) : chapter.href;
|
||||
tocHtml += `<a href="${rendition.book.path.directory + link}">${chapter.label}</a>`;
|
||||
tocHtml += '</li>';
|
||||
});
|
||||
tocHtml += '</ul>';
|
||||
elem.innerHTML = tocHtml;
|
||||
|
||||
this.replaceLinks(elem, (href) => {
|
||||
let relative = rendition.book.path.relative(href);
|
||||
rendition.display(relative);
|
||||
this.destroy();
|
||||
});
|
||||
|
||||
this._elem = elem;
|
||||
|
||||
this.bindEvents();
|
||||
|
||||
dialogHelper.open(elem);
|
||||
}
|
||||
}
|
|
@ -182,6 +182,7 @@ define(['require', 'browser', 'layoutManager', 'appSettings', 'pluginManager', '
|
|||
context.querySelector('#chkThemeVideo').checked = userSettings.enableThemeVideos();
|
||||
context.querySelector('#chkFadein').checked = userSettings.enableFastFadein();
|
||||
context.querySelector('#chkBackdrops').checked = userSettings.enableBackdrops();
|
||||
context.querySelector('#chkDetailsBanner').checked = userSettings.detailsBanner();
|
||||
|
||||
context.querySelector('#selectLanguage').value = userSettings.language() || '';
|
||||
context.querySelector('.selectDateTimeLocale').value = userSettings.dateTimeLocale() || '';
|
||||
|
@ -223,6 +224,7 @@ define(['require', 'browser', 'layoutManager', 'appSettings', 'pluginManager', '
|
|||
|
||||
userSettingsInstance.enableFastFadein(context.querySelector('#chkFadein').checked);
|
||||
userSettingsInstance.enableBackdrops(context.querySelector('#chkBackdrops').checked);
|
||||
userSettingsInstance.detailsBanner(context.querySelector('#chkDetailsBanner').checked);
|
||||
|
||||
if (user.Id === apiClient.getCurrentUserId()) {
|
||||
skinManager.setTheme(userSettingsInstance.theme());
|
||||
|
|
|
@ -163,6 +163,13 @@
|
|||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${EnableBlurhashHelp}</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription fldDetailsBanner">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkDetailsBanner" />
|
||||
<span>${EnableDetailsBanner}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${EnableDetailsBannerHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription fldBackdrops hide">
|
||||
<label>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -177,6 +177,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
|
|||
elem.addEventListener('pause', onPause);
|
||||
elem.addEventListener('playing', onPlaying);
|
||||
elem.addEventListener('play', onPlay);
|
||||
elem.addEventListener('waiting', onWaiting);
|
||||
}
|
||||
|
||||
function unBindEvents(elem) {
|
||||
|
@ -186,6 +187,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
|
|||
elem.removeEventListener('pause', onPause);
|
||||
elem.removeEventListener('playing', onPlaying);
|
||||
elem.removeEventListener('play', onPlay);
|
||||
elem.removeEventListener('waiting', onWaiting);
|
||||
}
|
||||
|
||||
self.stop = function (destroyPlayer) {
|
||||
|
@ -300,6 +302,10 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
|
|||
events.trigger(self, 'pause');
|
||||
}
|
||||
|
||||
function onWaiting() {
|
||||
events.trigger(self, 'waiting');
|
||||
}
|
||||
|
||||
function onError() {
|
||||
|
||||
var errorCode = this.error ? (this.error.code || 0) : 0;
|
||||
|
@ -456,6 +462,21 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
|
|||
return false;
|
||||
};
|
||||
|
||||
HtmlAudioPlayer.prototype.setPlaybackRate = function (value) {
|
||||
var mediaElement = this._mediaElement;
|
||||
if (mediaElement) {
|
||||
mediaElement.playbackRate = value;
|
||||
}
|
||||
};
|
||||
|
||||
HtmlAudioPlayer.prototype.getPlaybackRate = function () {
|
||||
var mediaElement = this._mediaElement;
|
||||
if (mediaElement) {
|
||||
return mediaElement.playbackRate;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
HtmlAudioPlayer.prototype.setVolume = function (val) {
|
||||
var mediaElement = this._mediaElement;
|
||||
if (mediaElement) {
|
||||
|
@ -499,5 +520,26 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
|
|||
|
||||
};
|
||||
|
||||
var supportedFeatures;
|
||||
|
||||
function getSupportedFeatures() {
|
||||
var list = [];
|
||||
var audio = document.createElement('audio');
|
||||
|
||||
if (typeof audio.playbackRate === 'number') {
|
||||
list.push('PlaybackRate');
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
HtmlAudioPlayer.prototype.supports = function (feature) {
|
||||
if (!supportedFeatures) {
|
||||
supportedFeatures = getSupportedFeatures();
|
||||
}
|
||||
|
||||
return supportedFeatures.indexOf(feature) !== -1;
|
||||
};
|
||||
|
||||
return HtmlAudioPlayer;
|
||||
});
|
||||
|
|
|
@ -799,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);
|
||||
}
|
||||
|
@ -927,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 || '') : '';
|
||||
|
@ -1349,6 +1354,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
videoElement.addEventListener('play', onPlay);
|
||||
videoElement.addEventListener('click', onClick);
|
||||
videoElement.addEventListener('dblclick', onDblClick);
|
||||
videoElement.addEventListener('waiting', onWaiting);
|
||||
if (options.backdropUrl) {
|
||||
videoElement.poster = options.backdropUrl;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
|
|
@ -203,9 +203,9 @@ define(['dom', 'loading', 'apphost', 'dialogHelper', 'connectionManager', 'image
|
|||
html += '<div class="cardContent">';
|
||||
|
||||
if (layoutManager.tv || !appHost.supports('externallinks')) {
|
||||
html += '<div class="cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center bottom;"></div>';
|
||||
html += '<div class="cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center center;background-size:contain;"></div>';
|
||||
} else {
|
||||
html += '<a is="emby-linkbutton" target="_blank" href="' + getDisplayUrl(image.Url, apiClient) + '" class="button-link cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center bottom;"></a>';
|
||||
html += '<a is="emby-linkbutton" target="_blank" href="' + getDisplayUrl(image.Url, apiClient) + '" class="button-link cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center center;background-size:contain"></a>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
|
|
@ -132,7 +132,7 @@ define(['dialogHelper', 'connectionManager', 'loading', 'dom', 'layoutManager',
|
|||
|
||||
var imageUrl = getImageUrl(currentItem, apiClient, image.ImageType, image.ImageIndex, { maxWidth: imageSize });
|
||||
|
||||
html += '<div class="cardImageContainer" style="background-image:url(\'' + imageUrl + '\');background-position:center bottom;"></div>';
|
||||
html += '<div class="cardImageContainer" style="background-image:url(\'' + imageUrl + '\');background-position:center center;background-size:contain;"></div>';
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
|
|
@ -46,7 +46,7 @@ define(['serverNotifications', 'playbackManager', 'events', 'globalize', 'requir
|
|||
function showNonPersistentNotification(title, options, timeoutMs) {
|
||||
|
||||
try {
|
||||
var notif = new Notification(title, options);
|
||||
var notif = new Notification(title, options); /* eslint-disable-line compat/compat */
|
||||
|
||||
if (notif.show) {
|
||||
notif.show();
|
||||
|
|
|
@ -1,23 +1,18 @@
|
|||
define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackManager', 'appRouter', 'appSettings', 'connectionManager'], function (browser, require, events, appHost, loading, dom, playbackManager, appRouter, appSettings, connectionManager) {
|
||||
'use strict';
|
||||
import connectionManager from 'connectionManager';
|
||||
|
||||
function PhotoPlayer() {
|
||||
|
||||
var self = this;
|
||||
|
||||
self.name = 'Photo Player';
|
||||
self.type = 'mediaplayer';
|
||||
self.id = 'photoplayer';
|
||||
|
||||
// Let any players created by plugins take priority
|
||||
self.priority = 1;
|
||||
export default class PhotoPlayer {
|
||||
constructor() {
|
||||
this.name = 'Photo Player';
|
||||
this.type = 'mediaplayer';
|
||||
this.id = 'photoplayer';
|
||||
this.priority = 1;
|
||||
}
|
||||
|
||||
PhotoPlayer.prototype.play = function (options) {
|
||||
play(options) {
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
||||
require(['slideshow'], function (slideshow) {
|
||||
import('slideshow').then(({default: slideshow}) => {
|
||||
|
||||
var index = options.startIndex || 0;
|
||||
|
||||
|
@ -41,12 +36,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
|
|||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
PhotoPlayer.prototype.canPlayMediaType = function (mediaType) {
|
||||
canPlayMediaType(mediaType) {
|
||||
|
||||
return (mediaType || '').toLowerCase() === 'photo';
|
||||
};
|
||||
|
||||
return PhotoPlayer;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -119,6 +119,7 @@ import connectionManager from 'connectionManager';
|
|||
const canSeek = playState.CanSeek || false;
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: title,
|
||||
artist: artist,
|
||||
|
@ -179,6 +180,7 @@ import connectionManager from 'connectionManager';
|
|||
|
||||
function hideMediaControls() {
|
||||
if ('mediaSession' in navigator) {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.metadata = null;
|
||||
} else {
|
||||
window.NativeShell.hideMediaSession();
|
||||
|
@ -210,26 +212,32 @@ import connectionManager from 'connectionManager';
|
|||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.setActionHandler('previoustrack', function () {
|
||||
execute('previousTrack');
|
||||
});
|
||||
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.setActionHandler('nexttrack', function () {
|
||||
execute('nextTrack');
|
||||
});
|
||||
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.setActionHandler('play', function () {
|
||||
execute('unpause');
|
||||
});
|
||||
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.setActionHandler('pause', function () {
|
||||
execute('pause');
|
||||
});
|
||||
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.setActionHandler('seekbackward', function () {
|
||||
execute('rewind');
|
||||
});
|
||||
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.setActionHandler('seekforward', function () {
|
||||
execute('fastForward');
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
@ -2182,7 +2187,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
|
|||
// Only used internally
|
||||
self.getCurrentTicks = getCurrentTicks;
|
||||
|
||||
function playPhotos(items, options, user) {
|
||||
function playOther(items, options, user) {
|
||||
|
||||
var playStartIndex = options.startIndex || 0;
|
||||
var player = getPlayer(items[playStartIndex], options);
|
||||
|
@ -2211,9 +2216,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
|
|||
return Promise.reject();
|
||||
}
|
||||
|
||||
if (firstItem.MediaType === 'Photo') {
|
||||
if (firstItem.MediaType === 'Photo' || firstItem.MediaType === 'Book') {
|
||||
|
||||
return playPhotos(items, options, user);
|
||||
return playOther(items, options, user);
|
||||
}
|
||||
|
||||
var apiClient = connectionManager.getApiClient(firstItem.ServerId);
|
||||
|
@ -3775,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;
|
||||
|
@ -3885,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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -58,7 +58,7 @@ define(['events', 'globalize'], function (events, globalize) {
|
|||
|
||||
return new Promise(function (resolve, reject) {
|
||||
require([pluginSpec], (pluginFactory) => {
|
||||
var plugin = new pluginFactory();
|
||||
var plugin = pluginFactory.default ? new pluginFactory.default() : new pluginFactory();
|
||||
|
||||
// See if it's already installed
|
||||
var existing = instance.pluginsList.filter(function (p) {
|
||||
|
|
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();
|
Loading…
Add table
Add a link
Reference in a new issue