1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge branch 'master' into hadicharara/added-support-for-rtl-layouts

This commit is contained in:
Hadi Charara 2022-10-12 08:29:53 -04:00
commit 104ad71ea7
128 changed files with 1242 additions and 1454 deletions

View file

@ -87,12 +87,10 @@ class AppRouter {
path = path.replace(this.baseUrl(), '');
if (this.currentRouteInfo && this.currentRouteInfo.path === path) {
// can't use this with home right now due to the back menu
if (this.currentRouteInfo.route.type !== 'home') {
loading.hide();
return Promise.resolve();
}
// can't use this with home right now due to the back menu
if (this.currentRouteInfo?.path === path && this.currentRouteInfo.route.type !== 'home') {
loading.hide();
return Promise.resolve();
}
this.promiseShow = new Promise((resolve) => {
@ -351,15 +349,13 @@ class AppRouter {
onRequestFail(_e, data) {
const apiClient = this;
if (data.status === 403) {
if (data.errorCode === 'ParentalControl') {
const isCurrentAllowed = appRouter.currentRouteInfo ? (appRouter.currentRouteInfo.route.anonymous || appRouter.currentRouteInfo.route.startup) : true;
if (data.status === 403 && data.errorCode === 'ParentalControl') {
const isCurrentAllowed = appRouter.currentRouteInfo ? (appRouter.currentRouteInfo.route.anonymous || appRouter.currentRouteInfo.route.startup) : true;
// Bounce to the login screen, but not if a password entry fails, obviously
if (!isCurrentAllowed) {
appRouter.showForcedLogoutMessage(globalize.translate('AccessRestrictedTryAgainLater'));
appRouter.showLocalLogin(apiClient.serverId());
}
// Bounce to the login screen, but not if a password entry fails, obviously
if (!isCurrentAllowed) {
appRouter.showForcedLogoutMessage(globalize.translate('AccessRestrictedTryAgainLater'));
appRouter.showLocalLogin(apiClient.serverId());
}
}
}

View file

@ -166,11 +166,7 @@ function supportsHtmlMediaAutoplay() {
return true;
}
if (browser.mobile) {
return false;
}
return true;
return !browser.mobile;
}
function supportsCue() {

View file

@ -10,24 +10,13 @@ import './backdrop.scss';
/* eslint-disable indent */
function enableAnimation() {
if (browser.slow) {
return false;
}
return true;
return !browser.slow;
}
function enableRotation() {
if (browser.tv) {
return false;
}
// Causes high cpu usage
if (browser.firefox) {
return false;
}
return true;
return !browser.tv
// Causes high cpu usage
&& !browser.firefox;
}
class Backdrop {

View file

@ -390,7 +390,8 @@ import { appRouter } from '../appRouter';
} else if (options.indexBy === 'ProductionYear') {
newIndexValue = item.ProductionYear;
} else if (options.indexBy === 'CommunityRating') {
newIndexValue = item.CommunityRating ? (Math.floor(item.CommunityRating) + (item.CommunityRating % 1 >= 0.5 ? 0.5 : 0)) + '+' : null;
const roundedRatingDecimal = item.CommunityRating % 1 >= 0.5 ? 0.5 : 0;
newIndexValue = item.CommunityRating ? (Math.floor(item.CommunityRating) + roundedRatingDecimal) + '+' : null;
}
if (newIndexValue !== currentIndexValue) {
@ -512,6 +513,7 @@ import { appRouter } from '../appRouter';
let imgType = null;
let itemId = null;
/* eslint-disable sonarjs/no-duplicated-branches */
if (options.preferThumb && item.ImageTags && item.ImageTags.Thumb) {
imgType = 'Thumb';
imgTag = item.ImageTags.Thumb;
@ -608,6 +610,7 @@ import { appRouter } from '../appRouter';
imgTag = item.ParentBackdropImageTags[0];
itemId = item.ParentBackdropItemId;
}
/* eslint-enable sonarjs/no-duplicated-branches */
if (!itemId) {
itemId = item.Id;
@ -790,10 +793,8 @@ import { appRouter } from '../appRouter';
const showOtherText = isOuterFooter ? !overlayText : overlayText;
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" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
}
if (isOuterFooter && options.cardLayout && layoutManager.mobile && options.cardFooterAside !== 'none') {
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
}
const cssClass = options.centerText ? 'cardText cardTextCentered' : 'cardText';
@ -803,33 +804,31 @@ import { appRouter } from '../appRouter';
const parentTitleUnderneath = item.Type === 'MusicAlbum' || item.Type === 'Audio' || item.Type === 'MusicVideo';
let titleAdded;
if (showOtherText) {
if ((options.showParentTitle || options.showParentTitleOrTitle) && !parentTitleUnderneath) {
if (isOuterFooter && item.Type === 'Episode' && item.SeriesName) {
if (item.SeriesId) {
lines.push(getTextActionButton({
Id: item.SeriesId,
ServerId: serverId,
Name: item.SeriesName,
Type: 'Series',
IsFolder: true
}));
} else {
lines.push(escapeHtml(item.SeriesName));
if (showOtherText && (options.showParentTitle || options.showParentTitleOrTitle) && !parentTitleUnderneath) {
if (isOuterFooter && item.Type === 'Episode' && item.SeriesName) {
if (item.SeriesId) {
lines.push(getTextActionButton({
Id: item.SeriesId,
ServerId: serverId,
Name: item.SeriesName,
Type: 'Series',
IsFolder: true
}));
} else {
lines.push(escapeHtml(item.SeriesName));
}
} else {
if (isUsingLiveTvNaming(item)) {
lines.push(escapeHtml(item.Name));
if (!item.EpisodeTitle && !item.IndexNumber) {
titleAdded = true;
}
} else {
if (isUsingLiveTvNaming(item)) {
lines.push(escapeHtml(item.Name));
const parentTitle = item.SeriesName || item.Series || item.Album || item.AlbumArtist || '';
if (!item.EpisodeTitle && !item.IndexNumber) {
titleAdded = true;
}
} else {
const parentTitle = item.SeriesName || item.Series || item.Album || item.AlbumArtist || '';
if (parentTitle || showTitle) {
lines.push(escapeHtml(parentTitle));
}
if (parentTitle || showTitle) {
lines.push(escapeHtml(parentTitle));
}
}
}
@ -987,10 +986,8 @@ import { appRouter } from '../appRouter';
}
}
if (options.showPersonRoleOrType) {
if (item.Role) {
lines.push(globalize.translate('PersonRole', escapeHtml(item.Role)));
}
if (options.showPersonRoleOrType && item.Role) {
lines.push(globalize.translate('PersonRole', escapeHtml(item.Role)));
}
}
@ -1010,13 +1007,11 @@ import { appRouter } from '../appRouter';
html += progressHtml;
}
if (html) {
if (!isOuterFooter || logoUrl || options.cardLayout) {
html = '<div class="' + footerClass + '">' + html;
if (html && (!isOuterFooter || logoUrl || options.cardLayout)) {
html = '<div class="' + footerClass + '">' + html;
//cardFooter
html += '</div>';
}
//cardFooter
html += '</div>';
}
return html;

View file

@ -34,10 +34,8 @@ import ServerConnections from '../ServerConnections';
let shape = (options.backdropShape || 'backdrop');
if (videoStream.Width && videoStream.Height) {
if ((videoStream.Width / videoStream.Height) <= 1.2) {
shape = (options.squareShape || 'square');
}
if (videoStream.Width && videoStream.Height && (videoStream.Width / videoStream.Height) <= 1.2) {
shape = (options.squareShape || 'square');
}
className += ` ${shape}Card`;

View file

@ -87,7 +87,8 @@ export default class channelMapper {
html += '</div>';
html += '</div>';
html += `<button class="btnMap autoSize" is="paper-icon-button-light" type="button" data-id="${channel.Id}" data-providerid="${channel.ProviderChannelId}"><span class="material-icons mode_edit" aria-hidden="true"></span></button>`;
return html += '</div>';
html += '</div>';
return html;
}
function getEditorHtml() {
@ -100,7 +101,8 @@ export default class channelMapper {
html += '</div>';
html += '</form>';
html += '</div>';
return html += '</div>';
html += '</div>';
return html;
}
function initEditor(dlg, options) {

View file

@ -1,7 +1,7 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FunctionComponent } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { localeWithSuffix } from '../../../scripts/dfnshelper';
import { getLocaleWithSuffix } from '../../../scripts/dfnshelper';
import globalize from '../../../scripts/globalize';
import cardBuilder from '../../cardbuilder/cardBuilder';
import IconButtonElement from '../../../elements/IconButtonElement';
@ -23,7 +23,7 @@ type IProps = {
const getLastSeenText = (lastActivityDate?: string | null) => {
if (lastActivityDate) {
return globalize.translate('LastSeen', formatDistanceToNow(Date.parse(lastActivityDate), localeWithSuffix));
return globalize.translate('LastSeen', formatDistanceToNow(Date.parse(lastActivityDate), getLocaleWithSuffix()));
}
return '';

View file

@ -57,6 +57,7 @@ import '../../assets/css/scrollstyles.scss';
if ((shouldClose || !isOpened(dlg)) && unlisten) {
unlisten();
unlisten = null;
}
if (shouldClose) {
@ -64,6 +65,22 @@ import '../../assets/css/scrollstyles.scss';
}
}
function finishClose() {
if (unlisten) {
unlisten();
unlisten = null;
}
dlg.dispatchEvent(new CustomEvent('close', {
bubbles: false,
cancelable: false
}));
resolve({
element: dlg
});
}
function onBackCommand(e) {
if (e.detail.command === 'back') {
e.preventDefault();
@ -79,6 +96,7 @@ import '../../assets/css/scrollstyles.scss';
if (unlisten) {
unlisten();
unlisten = null;
}
removeBackdrop(dlg);
@ -92,9 +110,13 @@ import '../../assets/css/scrollstyles.scss';
const state = history.location.state || {};
if (state.dialogs?.length > 0) {
if (state.dialogs[state.dialogs.length - 1] === hash) {
unlisten = history.listen(finishClose);
history.back();
} else if (state.dialogs.includes(hash)) {
console.warn('[dialogHelper] dialog "%s" was closed, but is not the last dialog opened', hash);
unlisten = history.listen(finishClose);
// Remove the closed dialog hash from the history state
history.replace(
`${history.location.pathname}${history.location.search}`,
@ -123,18 +145,9 @@ import '../../assets/css/scrollstyles.scss';
}
}
//resolve();
// if we just called history.back(), then use a timeout to allow the history events to fire first
setTimeout(() => {
dlg.dispatchEvent(new CustomEvent('close', {
bubbles: false,
cancelable: false
}));
resolve({
element: dlg
});
}, 1);
if (!unlisten) {
finishClose();
}
}
dlg.addEventListener('_close', onDialogClosed);
@ -262,6 +275,11 @@ import '../../assets/css/scrollstyles.scss';
}
}
const getAnimationEndHandler = (dlg, callback) => function handler() {
dom.removeEventListener(dlg, dom.whichAnimationEvent(), handler, { once: true });
callback();
};
function animateDialogOpen(dlg) {
const onAnimationFinish = () => {
focusManager.pushScope(dlg);
@ -277,15 +295,11 @@ import '../../assets/css/scrollstyles.scss';
};
if (enableAnimation()) {
const onFinish = () => {
dom.removeEventListener(dlg, dom.whichAnimationEvent(), onFinish, {
once: true
});
onAnimationFinish();
};
dom.addEventListener(dlg, dom.whichAnimationEvent(), onFinish, {
once: true
});
dom.addEventListener(
dlg,
dom.whichAnimationEvent(),
getAnimationEndHandler(dlg, onAnimationFinish),
{ once: true });
return;
}
@ -311,15 +325,12 @@ import '../../assets/css/scrollstyles.scss';
animated = false;
break;
}
const onFinish = () => {
dom.removeEventListener(dlg, dom.whichAnimationEvent(), onFinish, {
once: true
});
onAnimationFinish();
};
dom.addEventListener(dlg, dom.whichAnimationEvent(), onFinish, {
once: true
});
dom.addEventListener(
dlg,
dom.whichAnimationEvent(),
getAnimationEndHandler(dlg, onAnimationFinish),
{ once: true });
if (animated) {
return;

View file

@ -26,6 +26,7 @@
<option value="es_DO">Español (Dominicana)</option>
<option value="es-MX">Español (México)</option>
<option value="et">Eesti</option>
<option value="eu">Basque</option>
<option value="fa">فارسی</option>
<option value="fi">Suomi</option>
<option value="fil">Filipino</option>

View file

@ -132,6 +132,7 @@ function saveValues(context, settings, settingsKey) {
seriesStatuses.push(elems[i].getAttribute('data-filter'));
}
}
userSettings.setFilter(`${settingsKey}-filter-SeriesStatus`, seriesStatuses.join(','));
// Genres
const genres = [];

View file

@ -56,15 +56,8 @@ import scrollManager from './scrollManager';
}).join(',') + ',.focusable';
function isFocusable(elem) {
if (focusableTagNames.indexOf(elem.tagName) !== -1) {
return true;
}
if (elem.classList && elem.classList.contains('focusable')) {
return true;
}
return false;
return focusableTagNames.indexOf(elem.tagName) !== -1
|| (elem.classList?.contains('focusable'));
}
function normalizeFocusable(elem, originalElement) {
@ -97,11 +90,7 @@ import scrollManager from './scrollManager';
// Determines if a focusable element can be focused at a given point in time
function isCurrentlyFocusableInternal(elem) {
// http://stackoverflow.com/questions/19669786/check-if-element-is-visible-in-dom
if (elem.offsetParent === null) {
return false;
}
return true;
return elem.offsetParent !== null;
}
// Determines if a focusable element can be focused at a given point in time
@ -384,10 +373,11 @@ import scrollManager from './scrollManager';
// See if there's a focusable container, and if so, send the focus command to that
if (activeElement) {
const nearestElementFocusableParent = dom.parentWithClass(nearestElement, 'focusable');
if (nearestElementFocusableParent && nearestElementFocusableParent !== nearestElement) {
if (focusableContainer !== nearestElementFocusableParent) {
nearestElement = nearestElementFocusableParent;
}
if (nearestElementFocusableParent
&& nearestElementFocusableParent !== nearestElement
&& focusableContainer !== nearestElementFocusableParent
) {
nearestElement = nearestElementFocusableParent;
}
}
focus(nearestElement);

View file

@ -26,12 +26,8 @@ import { Events } from 'jellyfin-apiclient';
function canPlayNativeHls() {
const media = document.createElement('video');
if (media.canPlayType('application/x-mpegURL').replace(/no/, '') ||
media.canPlayType('application/vnd.apple.mpegURL').replace(/no/, '')) {
return true;
}
return false;
return !!(media.canPlayType('application/x-mpegURL').replace(/no/, '') ||
media.canPlayType('application/vnd.apple.mpegURL').replace(/no/, ''));
}
export function enableHlsJsPlayer(runTimeTicks, mediaType) {
@ -123,11 +119,10 @@ import { Events } from 'jellyfin-apiclient';
}
export function isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true;
}
return false;
return duration
&& !isNaN(duration)
&& duration !== Number.POSITIVE_INFINITY
&& duration !== Number.NEGATIVE_INFINITY;
}
function setCurrentTimeIfNeeded(element, seconds) {
@ -159,11 +154,15 @@ import { Events } from 'jellyfin-apiclient';
// (but rewinding cannot happen as the first event with media of non-empty duration)
console.debug(`seeking to ${seconds} on ${e.type} event`);
setCurrentTimeIfNeeded(element, seconds);
events.forEach(name => element.removeEventListener(name, onMediaChange));
events.forEach(name => {
element.removeEventListener(name, onMediaChange);
});
if (onMediaReady) onMediaReady();
}
};
events.forEach(name => element.addEventListener(name, onMediaChange));
events.forEach(name => {
element.addEventListener(name, onMediaChange);
});
}
}
}
@ -273,28 +272,23 @@ import { Events } from 'jellyfin-apiclient';
hls.on(Hls.Events.ERROR, function (event, data) {
console.error('HLS Error: Type: ' + data.type + ' Details: ' + (data.details || '') + ' Fatal: ' + (data.fatal || false));
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
// try to recover network error
if (data.response && data.response.code && data.response.code >= 400) {
console.debug('hls.js response error code: ' + data.response.code);
// try to recover network error
if (data.type === Hls.ErrorTypes.NETWORK_ERROR
&& data.response?.code && data.response.code >= 400
) {
console.debug('hls.js response error code: ' + data.response.code);
// Trigger failure differently depending on whether this is prior to start of playback, or after
hls.destroy();
// Trigger failure differently depending on whether this is prior to start of playback, or after
hls.destroy();
if (reject) {
reject('servererror');
reject = null;
} else {
onErrorInternal(instance, 'servererror');
}
if (reject) {
reject('servererror');
reject = null;
} else {
onErrorInternal(instance, 'servererror');
}
return;
}
break;
default:
break;
return;
}
if (data.fatal) {

View file

@ -5,15 +5,9 @@ import './indicators.scss';
import 'material-design-icons-iconfont';
export function enableProgressIndicator(item) {
if (item.MediaType === 'Video' && item.Type !== 'TvChannel') {
return true;
}
if (item.Type === 'AudioBook' || item.Type === 'AudioPodcast') {
return true;
}
return false;
return (item.MediaType === 'Video' && item.Type !== 'TvChannel')
|| item.Type === 'AudioBook'
|| item.Type === 'AudioPodcast';
}
export function getProgressHtml(pct, options) {

View file

@ -72,26 +72,25 @@ import toast from './toast/toast';
}
}
if (item.IsFolder || item.Type === 'MusicArtist' || item.Type === 'MusicGenre') {
if (item.CollectionType !== 'livetv') {
if (options.shuffle !== false) {
commands.push({
name: globalize.translate('Shuffle'),
id: 'shuffle',
icon: 'shuffle'
});
}
}
if ((item.IsFolder || item.Type === 'MusicArtist' || item.Type === 'MusicGenre')
&& item.CollectionType !== 'livetv'
&& options.shuffle !== false
) {
commands.push({
name: globalize.translate('Shuffle'),
id: 'shuffle',
icon: 'shuffle'
});
}
if (item.MediaType === 'Audio' || item.Type === 'MusicAlbum' || item.Type === 'MusicArtist' || item.Type === 'MusicGenre') {
if (options.instantMix !== false && !itemHelper.isLocalItem(item)) {
commands.push({
name: globalize.translate('InstantMix'),
id: 'instantmix',
icon: 'explore'
});
}
if ((item.MediaType === 'Audio' || item.Type === 'MusicAlbum' || item.Type === 'MusicArtist' || item.Type === 'MusicGenre')
&& options.instantMix !== false && !itemHelper.isLocalItem(item)
) {
commands.push({
name: globalize.translate('InstantMix'),
id: 'instantmix',
icon: 'explore'
});
}
if (commands.length) {
@ -180,57 +179,49 @@ import toast from './toast/toast';
}
const canEdit = itemHelper.canEdit(user, item);
if (canEdit) {
if (options.edit !== false && item.Type !== 'SeriesTimer') {
const text = (item.Type === 'Timer' || item.Type === 'SeriesTimer') ? globalize.translate('Edit') : globalize.translate('EditMetadata');
commands.push({
name: text,
id: 'edit',
icon: 'edit'
});
}
if (canEdit && options.edit !== false && item.Type !== 'SeriesTimer') {
const text = (item.Type === 'Timer' || item.Type === 'SeriesTimer') ? globalize.translate('Edit') : globalize.translate('EditMetadata');
commands.push({
name: text,
id: 'edit',
icon: 'edit'
});
}
if (itemHelper.canEditImages(user, item)) {
if (options.editImages !== false) {
commands.push({
name: globalize.translate('EditImages'),
id: 'editimages',
icon: 'image'
});
}
if (itemHelper.canEditImages(user, item) && options.editImages !== false) {
commands.push({
name: globalize.translate('EditImages'),
id: 'editimages',
icon: 'image'
});
}
if (canEdit) {
if (item.MediaType === 'Video' && item.Type !== 'TvChannel' && item.Type !== 'Program' && item.LocationType !== 'Virtual' && !(item.Type === 'Recording' && item.Status !== 'Completed')) {
if (options.editSubtitles !== false) {
commands.push({
name: globalize.translate('EditSubtitles'),
id: 'editsubtitles',
icon: 'closed_caption'
});
}
}
if (canEdit && item.MediaType === 'Video' && item.Type !== 'TvChannel' && item.Type !== 'Program'
&& item.LocationType !== 'Virtual'
&& !(item.Type === 'Recording' && item.Status !== 'Completed')
&& options.editSubtitles !== false
) {
commands.push({
name: globalize.translate('EditSubtitles'),
id: 'editsubtitles',
icon: 'closed_caption'
});
}
if (options.identify !== false) {
if (itemHelper.canIdentify(user, item)) {
commands.push({
name: globalize.translate('Identify'),
id: 'identify',
icon: 'edit'
});
}
if (options.identify !== false && itemHelper.canIdentify(user, item)) {
commands.push({
name: globalize.translate('Identify'),
id: 'identify',
icon: 'edit'
});
}
if (item.MediaSources) {
if (options.moremediainfo !== false) {
commands.push({
name: globalize.translate('MoreMediaInfo'),
id: 'moremediainfo',
icon: 'info'
});
}
if (item.MediaSources && options.moremediainfo !== false) {
commands.push({
name: globalize.translate('MoreMediaInfo'),
id: 'moremediainfo',
icon: 'info'
});
}
if (item.Type === 'Program' && options.record !== false) {
@ -240,11 +231,7 @@ import toast from './toast/toast';
id: 'record',
icon: 'fiber_manual_record'
});
}
}
if (item.Type === 'Program' && options.record !== false) {
if (!item.TimerId) {
} else {
commands.push({
name: globalize.translate('Record'),
id: 'record',
@ -277,26 +264,20 @@ import toast from './toast/toast';
});
}
if (!restrictOptions) {
if (options.share === true) {
if (itemHelper.canShare(item, user)) {
commands.push({
name: globalize.translate('Share'),
id: 'share',
icon: 'share'
});
}
}
if (!restrictOptions && options.share === true && itemHelper.canShare(item, user)) {
commands.push({
name: globalize.translate('Share'),
id: 'share',
icon: 'share'
});
}
if (options.sync !== false) {
if (itemHelper.canSync(user, item)) {
commands.push({
name: globalize.translate('Sync'),
id: 'sync',
icon: 'sync'
});
}
if (options.sync !== false && itemHelper.canSync(user, item)) {
commands.push({
name: globalize.translate('Sync'),
id: 'sync',
icon: 'sync'
});
}
if (options.openAlbum !== false && item.AlbumId && item.MediaType !== 'Photo') {

View file

@ -48,10 +48,8 @@ export function getDisplayName(item, options = {}) {
export function supportsAddingToCollection(item) {
const invalidTypes = ['Genre', 'MusicGenre', 'Studio', 'UserView', 'CollectionFolder', 'Audio', 'Program', 'Timer', 'SeriesTimer'];
if (item.Type === 'Recording') {
if (item.Status !== 'Completed') {
return false;
}
if (item.Type === 'Recording' && item.Status !== 'Completed') {
return false;
}
return !item.CollectionType && invalidTypes.indexOf(item.Type) === -1 && item.MediaType !== 'Photo' && !isLocalItem(item);
@ -74,10 +72,8 @@ export function supportsAddingToPlaylist(item) {
return false;
}
if (item.Type === 'Recording') {
if (item.Status !== 'Completed') {
return false;
}
if (item.Type === 'Recording' && item.Status !== 'Completed') {
return false;
}
if (isLocalItem(item)) {
@ -109,10 +105,8 @@ export function canEdit(user, item) {
return false;
}
if (item.Type === 'Recording') {
if (item.Status !== 'Completed') {
return false;
}
if (item.Type === 'Recording' && item.Status !== 'Completed') {
return false;
}
if (isLocalItem(item)) {
@ -123,33 +117,23 @@ export function canEdit(user, item) {
}
export function isLocalItem(item) {
if (item && item.Id && typeof item.Id === 'string' && item.Id.indexOf('local') === 0) {
return true;
}
return false;
return item && item.Id && typeof item.Id === 'string' && item.Id.indexOf('local') === 0;
}
export function canIdentify (user, item) {
const itemType = item.Type;
if (itemType === 'Movie' ||
itemType === 'Trailer' ||
itemType === 'Series' ||
itemType === 'BoxSet' ||
itemType === 'Person' ||
itemType === 'Book' ||
itemType === 'MusicAlbum' ||
itemType === 'MusicArtist' ||
itemType === 'MusicVideo') {
if (user.Policy.IsAdministrator) {
if (!isLocalItem(item)) {
return true;
}
}
}
return false;
return (itemType === 'Movie'
|| itemType === 'Trailer'
|| itemType === 'Series'
|| itemType === 'BoxSet'
|| itemType === 'Person'
|| itemType === 'Book'
|| itemType === 'MusicAlbum'
|| itemType === 'MusicArtist'
|| itemType === 'MusicVideo')
&& user.Policy.IsAdministrator
&& !isLocalItem(item);
}
export function canEditImages (user, item) {
@ -160,17 +144,11 @@ export function canEditImages (user, item) {
}
if (itemType === 'UserView') {
if (user.Policy.IsAdministrator) {
return true;
}
return false;
return !!user.Policy.IsAdministrator;
}
if (item.Type === 'Recording') {
if (item.Status !== 'Completed') {
return false;
}
if (item.Type === 'Recording' && item.Status !== 'Completed') {
return false;
}
return itemType !== 'Timer' && itemType !== 'SeriesTimer' && canEdit(user, item) && !isLocalItem(item);
@ -201,10 +179,8 @@ export function canShare (item, user) {
if (item.Type === 'SeriesTimer') {
return false;
}
if (item.Type === 'Recording') {
if (item.Status !== 'Completed') {
return false;
}
if (item.Type === 'Recording' && item.Status !== 'Completed') {
return false;
}
if (isLocalItem(item)) {
return false;
@ -234,29 +210,21 @@ export function canMarkPlayed (item) {
}
}
if (item.Type === 'Series' ||
item.Type === 'Season' ||
item.Type === 'BoxSet' ||
item.MediaType === 'Book' ||
item.MediaType === 'Recording') {
return true;
}
return false;
return item.Type === 'Series'
|| item.Type === 'Season'
|| item.Type === 'BoxSet'
|| item.MediaType === 'Book'
|| item.MediaType === 'Recording';
}
export function canRate (item) {
if (item.Type === 'Program'
|| item.Type === 'Timer'
|| item.Type === 'SeriesTimer'
|| item.Type === 'CollectionFolder'
|| item.Type === 'UserView'
|| item.Type === 'Channel'
|| !item.UserData) {
return false;
}
return true;
return item.Type !== 'Program'
&& item.Type !== 'Timer'
&& item.Type !== 'SeriesTimer'
&& item.Type !== 'CollectionFolder'
&& item.Type !== 'UserView'
&& item.Type !== 'Channel'
&& item.UserData;
}
export function canConvert (item, user) {
@ -287,11 +255,7 @@ export function canConvert (item, user) {
return false;
}
if (item.IsPlaceHolder) {
return false;
}
return true;
return !item.IsPlaceHolder;
}
export function canRefreshMetadata (item, user) {
@ -301,11 +265,10 @@ export function canRefreshMetadata (item, user) {
return false;
}
if (item.Type !== 'Timer' && item.Type !== 'SeriesTimer' && item.Type !== 'Program' && item.Type !== 'TvChannel' && !(item.Type === 'Recording' && item.Status !== 'Completed')) {
if (!isLocalItem(item)) {
return true;
}
}
return item.Type !== 'Timer' && item.Type !== 'SeriesTimer' && item.Type !== 'Program'
&& item.Type !== 'TvChannel'
&& !(item.Type === 'Recording' && item.Status !== 'Completed')
&& !isLocalItem(item);
}
return false;
@ -321,14 +284,12 @@ export function supportsMediaSourceSelection (item) {
if (!item.MediaSources || (item.MediaSources.length === 1 && item.MediaSources[0].Type === 'Placeholder')) {
return false;
}
if (item.EnableMediaSourceDisplay === false) {
return false;
}
if (item.EnableMediaSourceDisplay == null && item.SourceType && item.SourceType !== 'Library') {
return false;
if (item.EnableMediaSourceDisplay != null) {
return !!item.EnableMediaSourceDisplay;
}
return true;
return !item.SourceType || item.SourceType === 'Library';
}
export function sortTracks (trackA, trackB) {

View file

@ -4,13 +4,12 @@ import { Events } from 'jellyfin-apiclient';
function onUserDataChanged() {
const instance = this;
const eventsToMonitor = getEventsToMonitor(instance);
// TODO: Check user data change reason?
if (eventsToMonitor.indexOf('markfavorite') !== -1) {
instance.notifyRefreshNeeded();
} else if (eventsToMonitor.indexOf('markplayed') !== -1) {
if (eventsToMonitor.indexOf('markfavorite') !== -1
|| eventsToMonitor.indexOf('markplayed') !== -1
) {
instance.notifyRefreshNeeded();
}
}
@ -25,37 +24,18 @@ function getEventsToMonitor(instance) {
return [];
}
function onTimerCreated() {
function notifyTimerRefresh() {
const instance = this;
if (getEventsToMonitor(instance).indexOf('timers') !== -1) {
instance.notifyRefreshNeeded();
return;
}
}
function onSeriesTimerCreated() {
function notifySeriesTimerRefresh() {
const instance = this;
if (getEventsToMonitor(instance).indexOf('seriestimers') !== -1) {
instance.notifyRefreshNeeded();
return;
}
}
function onTimerCancelled() {
const instance = this;
if (getEventsToMonitor(instance).indexOf('timers') !== -1) {
instance.notifyRefreshNeeded();
return;
}
}
function onSeriesTimerCancelled() {
const instance = this;
if (getEventsToMonitor(instance).indexOf('seriestimers') !== -1) {
instance.notifyRefreshNeeded();
return;
}
}
@ -94,16 +74,14 @@ function onPlaybackStopped(e, stopInfo) {
const state = stopInfo.state;
const eventsToMonitor = getEventsToMonitor(instance);
if (state.NowPlayingItem && state.NowPlayingItem.MediaType === 'Video') {
if (state.NowPlayingItem?.MediaType === 'Video') {
if (eventsToMonitor.indexOf('videoplayback') !== -1) {
instance.notifyRefreshNeeded(true);
return;
}
} else if (state.NowPlayingItem && state.NowPlayingItem.MediaType === 'Audio') {
if (eventsToMonitor.indexOf('audioplayback') !== -1) {
instance.notifyRefreshNeeded(true);
return;
}
} else if (state.NowPlayingItem?.MediaType === 'Audio' && eventsToMonitor.indexOf('audioplayback') !== -1) {
instance.notifyRefreshNeeded(true);
return;
}
}
@ -128,10 +106,10 @@ class ItemsRefresher {
this.options = options || {};
addNotificationEvent(this, 'UserDataChanged', onUserDataChanged);
addNotificationEvent(this, 'TimerCreated', onTimerCreated);
addNotificationEvent(this, 'SeriesTimerCreated', onSeriesTimerCreated);
addNotificationEvent(this, 'TimerCancelled', onTimerCancelled);
addNotificationEvent(this, 'SeriesTimerCancelled', onSeriesTimerCancelled);
addNotificationEvent(this, 'TimerCreated', notifyTimerRefresh);
addNotificationEvent(this, 'SeriesTimerCreated', notifySeriesTimerRefresh);
addNotificationEvent(this, 'TimerCancelled', notifyTimerRefresh);
addNotificationEvent(this, 'SeriesTimerCancelled', notifySeriesTimerRefresh);
addNotificationEvent(this, 'LibraryChanged', onLibraryChanged);
addNotificationEvent(this, 'playbackstop', onPlaybackStopped, playbackManager);
}

View file

@ -383,7 +383,6 @@ import template from './libraryoptionseditor.template.html';
return setContentType(parent, contentType).then(function() {
libraryOptions && setLibraryOptions(parent, libraryOptions);
bindEvents(parent);
return;
});
});
}

View file

@ -328,10 +328,8 @@ import ServerConnections from '../ServerConnections';
textlines.push(datetime.getDisplayTime(datetime.parseISO8601Date(item.StartDate)));
}
if (options.showChannel) {
if (item.ChannelName) {
textlines.push(item.ChannelName);
}
if (options.showChannel && item.ChannelName) {
textlines.push(item.ChannelName);
}
let parentTitle = null;
@ -370,10 +368,8 @@ import ServerConnections from '../ServerConnections';
}
if (item.IsFolder) {
if (options.artist !== false) {
if (item.AlbumArtist && item.Type === 'MusicAlbum') {
if (options.artist !== false && item.AlbumArtist && item.Type === 'MusicAlbum') {
textlines.push(item.AlbumArtist);
}
}
} else {
if (options.artist) {
@ -386,10 +382,8 @@ import ServerConnections from '../ServerConnections';
}
}
if (item.Type === 'TvChannel') {
if (item.CurrentProgram) {
textlines.push(itemHelper.getDisplayName(item.CurrentProgram));
}
if (item.Type === 'TvChannel' && item.CurrentProgram) {
textlines.push(itemHelper.getDisplayName(item.CurrentProgram));
}
cssClass = 'listItemBody';
@ -405,19 +399,17 @@ import ServerConnections from '../ServerConnections';
html += getTextLinesHtml(textlines, isLargeStyle);
if (options.mediaInfo !== false) {
if (!enableSideMediaInfo) {
const mediaInfoClass = 'secondary listItemMediaInfo listItemBodyText';
if (options.mediaInfo !== false && !enableSideMediaInfo) {
const mediaInfoClass = 'secondary listItemMediaInfo listItemBodyText';
html += `<div class="${mediaInfoClass}">`;
html += mediaInfo.getPrimaryMediaInfoHtml(item, {
episodeTitle: false,
originalAirDate: false,
subtitles: false
html += `<div class="${mediaInfoClass}">`;
html += mediaInfo.getPrimaryMediaInfoHtml(item, {
episodeTitle: false,
originalAirDate: false,
subtitles: false
});
html += '</div>';
}
});
html += '</div>';
}
if (enableOverview && item.Overview) {
@ -428,20 +420,18 @@ import ServerConnections from '../ServerConnections';
html += '</div>';
if (options.mediaInfo !== false) {
if (enableSideMediaInfo) {
html += '<div class="secondary listItemMediaInfo">';
html += mediaInfo.getPrimaryMediaInfoHtml(item, {
if (options.mediaInfo !== false && enableSideMediaInfo) {
html += '<div class="secondary listItemMediaInfo">';
html += mediaInfo.getPrimaryMediaInfoHtml(item, {
year: false,
container: false,
episodeTitle: false,
criticRating: false,
endsAt: false
year: false,
container: false,
episodeTitle: false,
criticRating: false,
endsAt: false
});
html += '</div>';
}
});
html += '</div>';
}
if (!options.recordButton && (item.Type === 'Timer' || item.Type === 'Program')) {

View file

@ -129,17 +129,18 @@ import '../../elements/emby-button/emby-button';
}
}
if ((item.Type === 'Episode' || item.MediaType === 'Photo') && options.originalAirDate !== false) {
if (item.PremiereDate) {
try {
//don't modify date to locale if episode. Only Dates (not times) are stored, or editable in the edit metadata dialog
date = datetime.parseISO8601Date(item.PremiereDate, item.Type !== 'Episode');
if ((item.Type === 'Episode' || item.MediaType === 'Photo')
&& options.originalAirDate !== false
&& item.PremiereDate
) {
try {
//don't modify date to locale if episode. Only Dates (not times) are stored, or editable in the edit metadata dialog
date = datetime.parseISO8601Date(item.PremiereDate, item.Type !== 'Episode');
text = datetime.toLocaleDateString(date);
miscInfo.push(text);
} catch (e) {
console.error('error parsing date:', item.PremiereDate);
}
text = datetime.toLocaleDateString(date);
miscInfo.push(text);
} catch (e) {
console.error('error parsing date:', item.PremiereDate);
}
}
@ -239,17 +240,17 @@ import '../../elements/emby-button/emby-button';
}
}
if (options.year !== false) {
if (item.Type !== 'Series' && item.Type !== 'Episode' && item.Type !== 'Person' && item.MediaType !== 'Photo' && item.Type !== 'Program' && item.Type !== 'Season') {
if (item.ProductionYear) {
miscInfo.push(item.ProductionYear);
} else if (item.PremiereDate) {
try {
text = datetime.toLocaleString(datetime.parseISO8601Date(item.PremiereDate).getFullYear(), {useGrouping: false});
miscInfo.push(text);
} catch (e) {
console.error('error parsing date:', item.PremiereDate);
}
if (options.year !== false && item.Type !== 'Series' && item.Type !== 'Episode' && item.Type !== 'Person'
&& item.MediaType !== 'Photo' && item.Type !== 'Program' && item.Type !== 'Season'
) {
if (item.ProductionYear) {
miscInfo.push(item.ProductionYear);
} else if (item.PremiereDate) {
try {
text = datetime.toLocaleString(datetime.parseISO8601Date(item.PremiereDate).getFullYear(), {useGrouping: false});
miscInfo.push(text);
} catch (e) {
console.error('error parsing date:', item.PremiereDate);
}
}
}
@ -314,14 +315,12 @@ import '../../elements/emby-button/emby-button';
}
export function getEndsAt(item) {
if (item.MediaType === 'Video' && item.RunTimeTicks) {
if (!item.StartDate) {
let endDate = new Date().getTime() + (item.RunTimeTicks / 10000);
endDate = new Date(endDate);
if (item.MediaType === 'Video' && item.RunTimeTicks && !item.StartDate) {
let endDate = new Date().getTime() + (item.RunTimeTicks / 10000);
endDate = new Date(endDate);
const displayTime = datetime.getDisplayTime(endDate);
return globalize.translate('EndsAtValue', displayTime);
}
const displayTime = datetime.getDisplayTime(endDate);
return globalize.translate('EndsAtValue', displayTime);
}
return null;

View file

@ -444,12 +444,10 @@ import { appRouter } from '../appRouter';
options = options || {};
options.type = options.type || 'Primary';
if (options.type === 'Primary') {
if (item.SeriesPrimaryImageTag) {
options.tag = item.SeriesPrimaryImageTag;
if (options.type === 'Primary' && item.SeriesPrimaryImageTag) {
options.tag = item.SeriesPrimaryImageTag;
return ServerConnections.getApiClient(item.ServerId).getScaledImageUrl(item.SeriesId, options);
}
return ServerConnections.getApiClient(item.ServerId).getScaledImageUrl(item.SeriesId, options);
}
if (options.type === 'Thumb') {

View file

@ -19,11 +19,7 @@ function enableLocalPlaylistManagement(player) {
return false;
}
if (player.isLocalPlayer) {
return true;
}
return false;
return player.isLocalPlayer;
}
function bindToFullscreenChange(player) {
@ -44,10 +40,8 @@ function triggerPlayerChange(playbackManagerInstance, newPlayer, newTarget, prev
return;
}
if (newTarget && previousTargetInfo) {
if (newTarget.id === previousTargetInfo.id) {
return;
}
if (newTarget && previousTargetInfo && newTarget.id === previousTargetInfo.id) {
return;
}
Events.trigger(playbackManagerInstance, 'playerchange', [newPlayer, newTarget, previousPlayer]);
@ -227,11 +221,7 @@ function getParam(name, url) {
}
function isAutomaticPlayer(player) {
if (player.isLocalPlayer) {
return true;
}
return false;
return player.isLocalPlayer;
}
function getAutomaticPlayers(instance, forceLocalPlayer) {
@ -246,10 +236,7 @@ function getAutomaticPlayers(instance, forceLocalPlayer) {
}
function isServerItem(item) {
if (!item.Id) {
return false;
}
return true;
return !!item.Id;
}
function enableIntros(item) {
@ -501,10 +488,10 @@ function getPlaybackInfo(player,
}
// lastly, enforce player overrides for special situations
if (query.EnableDirectStream !== false) {
if (player.supportsPlayMethod && !player.supportsPlayMethod('DirectStream', item)) {
query.EnableDirectStream = false;
}
if (query.EnableDirectStream !== false
&& player.supportsPlayMethod && !player.supportsPlayMethod('DirectStream', item)
) {
query.EnableDirectStream = false;
}
if (player.getDirectPlayProtocols) {
@ -569,10 +556,10 @@ function getLiveStream(player, apiClient, item, playSessionId, deviceProfile, ma
}
// lastly, enforce player overrides for special situations
if (query.EnableDirectStream !== false) {
if (player.supportsPlayMethod && !player.supportsPlayMethod('DirectStream', item)) {
query.EnableDirectStream = false;
}
if (query.EnableDirectStream !== false
&& player.supportsPlayMethod && !player.supportsPlayMethod('DirectStream', item)
) {
query.EnableDirectStream = false;
}
return apiClient.ajax({
@ -963,10 +950,8 @@ class PlaybackManager {
self.isPlaying = function (player) {
player = player || self._currentPlayer;
if (player) {
if (player.isPlaying) {
return player.isPlaying();
}
if (player?.isPlaying) {
return player.isPlaying();
}
return player != null && player.currentSrc() != null;
@ -975,10 +960,8 @@ class PlaybackManager {
self.isPlayingMediaType = function (mediaType, player) {
player = player || self._currentPlayer;
if (player) {
if (player.isPlaying) {
return player.isPlaying(mediaType);
}
if (player?.isPlaying) {
return player.isPlaying(mediaType);
}
if (self.isPlaying(player)) {
@ -1027,10 +1010,8 @@ class PlaybackManager {
return true;
}
if (item.LocationType === 'Virtual') {
if (itemType !== 'Program') {
return false;
}
if (item.LocationType === 'Virtual' && itemType !== 'Program') {
return false;
}
if (itemType === 'Program') {
@ -2486,8 +2467,8 @@ class PlaybackManager {
playMethod = 'DirectPlay';
} else if (mediaSource.StreamUrl) {
// Only used for audio
playMethod = 'Transcode';
mediaUrl = mediaSource.StreamUrl;
// Use the default playMethod value of Transcode
} else if (mediaSource.SupportsDirectPlay || mediaSource.SupportsDirectStream) {
directOptions = {
Static: true,
@ -3015,11 +2996,8 @@ class PlaybackManager {
function enablePlaybackRetryWithTranscoding(streamInfo, errorType, currentlyPreventsVideoStreamCopy, currentlyPreventsAudioStreamCopy) {
// mediadecodeerror, medianotsupported, network, servererror
if (streamInfo.mediaSource.SupportsTranscoding && (!currentlyPreventsVideoStreamCopy || !currentlyPreventsAudioStreamCopy)) {
return true;
}
return false;
return streamInfo.mediaSource.SupportsTranscoding
&& (!currentlyPreventsVideoStreamCopy || !currentlyPreventsAudioStreamCopy);
}
function onPlaybackError(e, error) {
@ -3300,10 +3278,10 @@ class PlaybackManager {
reportPlayback(self, state, player, reportPlaylist, serverId, 'reportPlaybackProgress', progressEventName);
}
if (streamInfo && streamInfo.liveStreamId) {
if (new Date().getTime() - (streamInfo.lastMediaInfoQuery || 0) >= 600000) {
getLiveStreamMediaInfo(player, streamInfo, self.currentMediaSource(player), streamInfo.liveStreamId, serverId);
}
if (streamInfo?.liveStreamId
&& (new Date().getTime() - (streamInfo.lastMediaInfoQuery || 0) >= 600000)
) {
getLiveStreamMediaInfo(player, streamInfo, self.currentMediaSource(player), streamInfo.liveStreamId, serverId);
}
}
}
@ -3568,10 +3546,8 @@ class PlaybackManager {
}
getBufferedRanges(player = this._currentPlayer) {
if (player) {
if (player.getBufferedRanges) {
return player.getBufferedRanges();
}
if (player?.getBufferedRanges) {
return player.getBufferedRanges();
}
return [];
@ -3842,19 +3818,15 @@ class PlaybackManager {
removeActivePlayer(name) {
const playerInfo = this.getPlayerInfo();
if (playerInfo) {
if (playerInfo.name === name) {
this.setDefaultPlayerActive();
}
if (playerInfo?.name === name) {
this.setDefaultPlayerActive();
}
}
removeActiveTarget(id) {
const playerInfo = this.getPlayerInfo();
if (playerInfo) {
if (playerInfo.id === id) {
this.setDefaultPlayerActive();
}
if (playerInfo?.id === id) {
this.setDefaultPlayerActive();
}
}

View file

@ -29,10 +29,8 @@ function mirrorIfEnabled(info) {
if (info && playbackManager.enableDisplayMirroring()) {
const getPlayerInfo = playbackManager.getPlayerInfo();
if (getPlayerInfo) {
if (!getPlayerInfo.isLocalPlayer && getPlayerInfo.supportedCommands.indexOf('DisplayContent') !== -1) {
mirrorItem(info, playbackManager.getCurrentPlayer());
}
if (getPlayerInfo && !getPlayerInfo.isLocalPlayer && getPlayerInfo.supportedCommands.indexOf('DisplayContent') !== -1) {
mirrorItem(info, playbackManager.getCurrentPlayer());
}
}
}
@ -85,11 +83,9 @@ function getIcon(target) {
export function show(button) {
const currentPlayerInfo = playbackManager.getPlayerInfo();
if (currentPlayerInfo) {
if (!currentPlayerInfo.isLocalPlayer) {
showActivePlayerMenu(currentPlayerInfo);
return;
}
if (currentPlayerInfo && !currentPlayerInfo.isLocalPlayer) {
showActivePlayerMenu(currentPlayerInfo);
return;
}
const currentPlayerId = currentPlayerInfo ? currentPlayerInfo.id : null;
@ -299,7 +295,6 @@ document.addEventListener('viewshow', function (e) {
mirrorIfEnabled({
item: item
});
return;
}
});

View file

@ -238,7 +238,6 @@ function showWithUser(options, player, user) {
return actionsheet.show({
items: menuItems,
resolveOnClick: true,
positionTo: options.positionTo
}).then(function (id) {
return handleSelectedOption(id, options, player);

View file

@ -4,7 +4,7 @@ import globalize from '../../scripts/globalize';
import layoutManager from '../layoutManager';
import { playbackManager } from '../playback/playbackmanager';
import playMethodHelper from '../playback/playmethodhelper';
import SyncPlay from '../../components/syncPlay/core';
import SyncPlay from '../../plugins/syncPlay/core';
import './playerstats.scss';
import ServerConnections from '../ServerConnections';

View file

@ -4,7 +4,7 @@ import dialogHelper from '../dialogHelper/dialogHelper';
import loading from '../loading/loading';
import layoutManager from '../layoutManager';
import { playbackManager } from '../playback/playbackmanager';
import SyncPlay from '../../components/syncPlay/core';
import SyncPlay from '../../plugins/syncPlay/core';
import * as userSettings from '../../scripts/settings/userSettings';
import { appRouter } from '../appRouter';
import globalize from '../../scripts/globalize';

View file

@ -106,10 +106,8 @@ function getIndicatorIcon(item) {
return 'fiber_manual_record';
}
if (item.SeriesTimerId) {
if (status !== 'Cancelled') {
return 'fiber_smart_record';
}
if (item.SeriesTimerId && status !== 'Cancelled') {
return 'fiber_smart_record';
}
return 'fiber_manual_record';

View file

@ -129,7 +129,6 @@ function executeCloseAction(action, programId, serverId) {
serverId: serverId
});
});
return;
}
}

View file

@ -61,40 +61,20 @@ function fetchData(instance) {
function onTimerChangedExternally(e, apiClient, data) {
const options = this.options;
let refresh = false;
if (data.Id) {
if (this.TimerId === data.Id) {
refresh = true;
}
}
if (data.ProgramId && options) {
if (options.programId === data.ProgramId) {
refresh = true;
}
}
if (refresh) {
if ((data.Id && this.TimerId === data.Id)
|| (data.ProgramId && options && options.programId === data.ProgramId)
) {
this.refresh();
}
}
function onSeriesTimerChangedExternally(e, apiClient, data) {
const options = this.options;
let refresh = false;
if (data.Id) {
if (this.SeriesTimerId === data.Id) {
refresh = true;
}
}
if (data.ProgramId && options) {
if (options.programId === data.ProgramId) {
refresh = true;
}
}
if (refresh) {
if ((data.Id && this.SeriesTimerId === data.Id)
|| (data.ProgramId && options && options.programId === data.ProgramId)
) {
this.refresh();
}
}

View file

@ -479,11 +479,7 @@ import layoutManager from './layoutManager';
* Returns true if smooth scroll must be used.
*/
function useSmoothScroll() {
if (browser.tizen) {
return true;
}
return false;
return !!browser.tizen;
}
/**

View file

@ -46,11 +46,9 @@ function getImageUrl(item, options, apiClient) {
return apiClient.getScaledImageUrl(item.Id, options);
}
if (options.type === 'Primary') {
if (item.AlbumId && item.AlbumPrimaryImageTag) {
options.tag = item.AlbumPrimaryImageTag;
return apiClient.getScaledImageUrl(item.AlbumId, options);
}
if (options.type === 'Primary' && item.AlbumId && item.AlbumPrimaryImageTag) {
options.tag = item.AlbumPrimaryImageTag;
return apiClient.getScaledImageUrl(item.AlbumId, options);
}
return null;

View file

@ -114,10 +114,8 @@ function fillSubtitleList(context, item) {
itemHtml += '</a>';
itemHtml += '</div>';
if (!layoutManager.tv) {
if (s.Path) {
itemHtml += '<button is="paper-icon-button-light" data-index="' + s.Index + '" title="' + globalize.translate('Delete') + '" class="btnDelete listItemButton"><span class="material-icons delete" aria-hidden="true"></span></button>';
}
if (!layoutManager.tv && s.Path) {
itemHtml += '<button is="paper-icon-button-light" data-index="' + s.Index + '" title="' + globalize.translate('Delete') + '" class="btnDelete listItemButton"><span class="material-icons delete" aria-hidden="true"></span></button>';
}
itemHtml += '</' + tagName + '>';
@ -336,12 +334,8 @@ function showDownloadOptions(button, context, subtitleId) {
positionTo: button
}).then(function (id) {
switch (id) {
case 'download':
downloadRemoteSubtitles(context, subtitleId);
break;
default:
break;
if (id === 'download') {
downloadRemoteSubtitles(context, subtitleId);
}
});
});

View file

@ -144,33 +144,32 @@ class SubtitleSync {
}
toggle(action) {
if (action && !['hide', 'forceToHide'].includes(action)) {
console.warn('SubtitleSync.toggle called with invalid action', action);
return;
}
if (player && playbackManager.supportSubtitleOffset(player)) {
/* eslint-disable no-fallthrough */
switch (action) {
case undefined:
// if showing subtitle sync is enabled and if there is an external subtitle stream enabled
if (playbackManager.isShowingSubtitleOffsetEnabled(player) && playbackManager.canHandleOffsetOnCurrentSubtitle(player)) {
// if no subtitle offset is defined or element has focus (offset being defined)
if (!(playbackManager.getPlayerSubtitleOffset(player) || subtitleSyncTextField.hasFocus)) {
// set default offset to '0' = 50%
subtitleSyncSlider.value = '50';
subtitleSyncTextField.textContent = '0s';
playbackManager.setSubtitleOffset(0, player);
}
// show subtitle sync
subtitleSyncContainer.classList.remove('hide');
break; // stop here
} // else continue and hide
case 'hide':
// only break if element has focus
if (subtitleSyncTextField.hasFocus) {
break;
if (!action) {
// if showing subtitle sync is enabled and if there is an external subtitle stream enabled
if (playbackManager.isShowingSubtitleOffsetEnabled(player) && playbackManager.canHandleOffsetOnCurrentSubtitle(player)) {
// if no subtitle offset is defined or element has focus (offset being defined)
if (!(playbackManager.getPlayerSubtitleOffset(player) || subtitleSyncTextField.hasFocus)) {
// set default offset to '0' = 50%
subtitleSyncSlider.value = '50';
subtitleSyncTextField.textContent = '0s';
playbackManager.setSubtitleOffset(0, player);
}
case 'forceToHide':
subtitleSyncContainer.classList.add('hide');
break;
// show subtitle sync
subtitleSyncContainer.classList.remove('hide');
return;
}
} else if (action === 'hide' && subtitleSyncTextField.hasFocus) {
// do not hide if element has focus
return;
}
/* eslint-enable no-fallthrough */
subtitleSyncContainer.classList.add('hide');
}
}
}

View file

@ -1,233 +0,0 @@
/**
* Module that exposes SyncPlay calls to external modules.
* @module components/syncPlay/core/Controller
*/
import * as Helper from './Helper';
/**
* Class that exposes SyncPlay calls to external modules.
*/
class Controller {
constructor() {
this.manager = null;
}
/**
* Initializes the controller.
* @param {Manager} syncPlayManager The SyncPlay manager.
*/
init(syncPlayManager) {
this.manager = syncPlayManager;
}
/**
* Toggles playback status in SyncPlay group.
*/
playPause() {
if (this.manager.isPlaying()) {
this.pause();
} else {
this.unpause();
}
}
/**
* Unpauses playback in SyncPlay group.
*/
unpause() {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayUnpause();
}
/**
* Pauses playback in SyncPlay group.
*/
pause() {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayPause();
// Pause locally as well, to give the user some little control.
const playerWrapper = this.manager.getPlayerWrapper();
playerWrapper.localPause();
}
/**
* Seeks playback to specified position in SyncPlay group.
* @param {number} positionTicks The position.
*/
seek(positionTicks) {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlaySeek({
PositionTicks: positionTicks
});
}
/**
* Starts playback in SyncPlay group.
* @param {Object} options The play data.
*/
play(options) {
const apiClient = this.manager.getApiClient();
const sendPlayRequest = (items) => {
const queue = items.map(item => item.Id);
return apiClient.requestSyncPlaySetNewQueue({
PlayingQueue: queue,
PlayingItemPosition: options.startIndex ? options.startIndex : 0,
StartPositionTicks: options.startPositionTicks ? options.startPositionTicks : 0
});
};
if (options.items) {
return Helper.translateItemsForPlayback(apiClient, options.items, options).then(sendPlayRequest);
} else {
return Helper.getItemsForPlayback(apiClient, {
Ids: options.ids.join(',')
}).then(function (result) {
return Helper.translateItemsForPlayback(apiClient, result.Items, options).then(sendPlayRequest);
});
}
}
/**
* Sets current playing item in SyncPlay group.
* @param {string} playlistItemId The item playlist identifier.
*/
setCurrentPlaylistItem(playlistItemId) {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlaySetPlaylistItem({
PlaylistItemId: playlistItemId
});
}
/**
* Clears the playlist of a SyncPlay group.
* @param {Array} clearPlayingItem Whether to remove the playing item as well.
*/
clearPlaylist(clearPlayingItem = false) {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayRemoveFromPlaylist({
ClearPlaylist: true,
ClearPlayingItem: clearPlayingItem
});
}
/**
* Removes items from SyncPlay group playlist.
* @param {Array} playlistItemIds The items to remove.
*/
removeFromPlaylist(playlistItemIds) {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayRemoveFromPlaylist({
PlaylistItemIds: playlistItemIds
});
}
/**
* Moves an item in the SyncPlay group playlist.
* @param {string} playlistItemId The item playlist identifier.
* @param {number} newIndex The new position.
*/
movePlaylistItem(playlistItemId, newIndex) {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayMovePlaylistItem({
PlaylistItemId: playlistItemId,
NewIndex: newIndex
});
}
/**
* Adds items to the SyncPlay group playlist.
* @param {Object} options The items to add.
* @param {string} mode The queue mode, optional.
*/
queue(options, mode = 'Queue') {
const apiClient = this.manager.getApiClient();
if (options.items) {
Helper.translateItemsForPlayback(apiClient, options.items, options).then((items) => {
const itemIds = items.map(item => item.Id);
apiClient.requestSyncPlayQueue({
ItemIds: itemIds,
Mode: mode
});
});
} else {
Helper.getItemsForPlayback(apiClient, {
Ids: options.ids.join(',')
}).then(function (result) {
Helper.translateItemsForPlayback(apiClient, result.Items, options).then((items) => {
const itemIds = items.map(item => item.Id);
apiClient.requestSyncPlayQueue({
ItemIds: itemIds,
Mode: mode
});
});
});
}
}
/**
* Adds items to the SyncPlay group playlist after the playing item.
* @param {Object} options The items to add.
*/
queueNext(options) {
this.queue(options, 'QueueNext');
}
/**
* Plays next item from playlist in SyncPlay group.
*/
nextItem() {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayNextItem({
PlaylistItemId: this.manager.getQueueCore().getCurrentPlaylistItemId()
});
}
/**
* Plays previous item from playlist in SyncPlay group.
*/
previousItem() {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayPreviousItem({
PlaylistItemId: this.manager.getQueueCore().getCurrentPlaylistItemId()
});
}
/**
* Sets the repeat mode in SyncPlay group.
* @param {string} mode The repeat mode.
*/
setRepeatMode(mode) {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlaySetRepeatMode({
Mode: mode
});
}
/**
* Sets the shuffle mode in SyncPlay group.
* @param {string} mode The shuffle mode.
*/
setShuffleMode(mode) {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlaySetShuffleMode({
Mode: mode
});
}
/**
* Toggles the shuffle mode in SyncPlay group.
*/
toggleShuffleMode() {
let mode = this.manager.getQueueCore().getShuffleMode();
mode = mode === 'Sorted' ? 'Shuffle' : 'Sorted';
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlaySetShuffleMode({
Mode: mode
});
}
}
export default Controller;

View file

@ -1,230 +0,0 @@
/**
* Module that offers some utility functions.
* @module components/syncPlay/core/Helper
*/
import { Events } from 'jellyfin-apiclient';
/**
* Constants
*/
export const WaitForEventDefaultTimeout = 30000; // milliseconds
export const WaitForPlayerEventTimeout = 500; // milliseconds
export const TicksPerMillisecond = 10000.0;
/**
* Waits for an event to be triggered on an object. An optional timeout can specified after which the promise is rejected.
* @param {Object} emitter Object on which to listen for events.
* @param {string} eventType Event name to listen for.
* @param {number} timeout Time before rejecting promise if event does not trigger, in milliseconds.
* @param {Array} rejectEventTypes Event names to listen for and abort the waiting.
* @returns {Promise} A promise that resolves when the event is triggered.
*/
export function waitForEventOnce(emitter, eventType, timeout, rejectEventTypes) {
return new Promise((resolve, reject) => {
let rejectTimeout;
if (timeout) {
rejectTimeout = setTimeout(() => {
reject('Timed out.');
}, timeout);
}
const clearAll = () => {
Events.off(emitter, eventType, callback);
if (rejectTimeout) {
clearTimeout(rejectTimeout);
}
if (Array.isArray(rejectEventTypes)) {
rejectEventTypes.forEach(eventName => {
Events.off(emitter, eventName, rejectCallback);
});
}
};
const callback = () => {
clearAll();
resolve(arguments);
};
const rejectCallback = (event) => {
clearAll();
reject(event.type);
};
Events.on(emitter, eventType, callback);
if (Array.isArray(rejectEventTypes)) {
rejectEventTypes.forEach(eventName => {
Events.on(emitter, eventName, rejectCallback);
});
}
});
}
/**
* Converts a given string to a Guid string.
* @param {string} input The input string.
* @returns {string} The Guid string.
*/
export function stringToGuid(input) {
return input.replace(/([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/, '$1-$2-$3-$4-$5');
}
export function getItemsForPlayback(apiClient, query) {
if (query.Ids && query.Ids.split(',').length === 1) {
const itemId = query.Ids.split(',');
return apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) {
return {
Items: [item]
};
});
} else {
query.Limit = query.Limit || 300;
query.Fields = 'Chapters';
query.ExcludeLocationTypes = 'Virtual';
query.EnableTotalRecordCount = false;
query.CollapseBoxSetItems = false;
return apiClient.getItems(apiClient.getCurrentUserId(), query);
}
}
function mergePlaybackQueries(obj1, obj2) {
const query = Object.assign(obj1, obj2);
const filters = query.Filters ? query.Filters.split(',') : [];
if (filters.indexOf('IsNotFolder') === -1) {
filters.push('IsNotFolder');
}
query.Filters = filters.join(',');
return query;
}
export function translateItemsForPlayback(apiClient, items, options) {
if (items.length > 1 && options && options.ids) {
// Use the original request id array for sorting the result in the proper order.
items.sort(function (a, b) {
return options.ids.indexOf(a.Id) - options.ids.indexOf(b.Id);
});
}
const firstItem = items[0];
let promise;
const queryOptions = options.queryOptions || {};
if (firstItem.Type === 'Program') {
promise = getItemsForPlayback(apiClient, {
Ids: firstItem.ChannelId
});
} else if (firstItem.Type === 'Playlist') {
promise = getItemsForPlayback(apiClient, {
ParentId: firstItem.Id,
SortBy: options.shuffle ? 'Random' : null
});
} else if (firstItem.Type === 'MusicArtist') {
promise = getItemsForPlayback(apiClient, {
ArtistIds: firstItem.Id,
Filters: 'IsNotFolder',
Recursive: true,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Audio'
});
} else if (firstItem.MediaType === 'Photo') {
promise = getItemsForPlayback(apiClient, {
ParentId: firstItem.ParentId,
Filters: 'IsNotFolder',
// Setting this to true may cause some incorrect sorting.
Recursive: false,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Photo,Video'
}).then(function (result) {
let index = result.Items.map(function (i) {
return i.Id;
}).indexOf(firstItem.Id);
if (index === -1) {
index = 0;
}
options.startIndex = index;
return Promise.resolve(result);
});
} else if (firstItem.Type === 'PhotoAlbum') {
promise = getItemsForPlayback(apiClient, {
ParentId: firstItem.Id,
Filters: 'IsNotFolder',
// Setting this to true may cause some incorrect sorting.
Recursive: false,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Photo,Video',
Limit: 1000
});
} else if (firstItem.Type === 'MusicGenre') {
promise = getItemsForPlayback(apiClient, {
GenreIds: firstItem.Id,
Filters: 'IsNotFolder',
Recursive: true,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Audio'
});
} else if (firstItem.IsFolder) {
let sortBy = null;
if (options.shuffle) {
sortBy = 'Random';
} else if (firstItem.Type === 'BoxSet') {
sortBy = 'SortName';
}
promise = getItemsForPlayback(apiClient, mergePlaybackQueries({
ParentId: firstItem.Id,
Filters: 'IsNotFolder',
Recursive: true,
// These are pre-sorted.
SortBy: sortBy,
MediaTypes: 'Audio,Video'
}, queryOptions));
} else if (firstItem.Type === 'Episode' && items.length === 1) {
promise = new Promise(function (resolve, reject) {
apiClient.getCurrentUser().then(function (user) {
if (!user.Configuration.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
resolve(null);
return;
}
apiClient.getEpisodes(firstItem.SeriesId, {
IsVirtualUnaired: false,
IsMissing: false,
UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters'
}).then(function (episodesResult) {
let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) {
if (foundItem) {
return true;
}
if (e.Id === firstItem.Id) {
foundItem = true;
return true;
}
return false;
});
episodesResult.TotalRecordCount = episodesResult.Items.length;
resolve(episodesResult);
}, reject);
});
});
}
if (promise) {
return promise.then(function (result) {
return result ? result.Items : items;
});
} else {
return Promise.resolve(items);
}
}

View file

@ -1,498 +0,0 @@
/**
* Module that manages the SyncPlay feature.
* @module components/syncPlay/core/Manager
*/
import { Events } from 'jellyfin-apiclient';
import * as Helper from './Helper';
import TimeSyncCore from './timeSync/TimeSyncCore';
import PlaybackCore from './PlaybackCore';
import QueueCore from './QueueCore';
import Controller from './Controller';
import toast from '../../toast/toast';
import globalize from '../../../scripts/globalize';
/**
* Class that manages the SyncPlay feature.
*/
class Manager {
/**
* Creates an instance of SyncPlay Manager.
* @param {PlayerFactory} playerFactory The PlayerFactory instance.
*/
constructor(playerFactory) {
this.playerFactory = playerFactory;
this.apiClient = null;
this.timeSyncCore = new TimeSyncCore();
this.playbackCore = new PlaybackCore();
this.queueCore = new QueueCore();
this.controller = new Controller();
this.syncMethod = 'None'; // Used for stats.
this.groupInfo = null;
this.syncPlayEnabledAt = null; // Server time of when SyncPlay has been enabled.
this.syncPlayReady = false; // SyncPlay is ready after first ping to server.
this.queuedCommand = null; // Queued playback command, applied when SyncPlay is ready.
this.followingGroupPlayback = true; // Follow or ignore group playback.
this.lastPlaybackCommand = null; // Last received playback command from server, tracks state of group.
this.currentPlayer = null;
this.playerWrapper = null;
}
/**
* Initialise SyncPlay.
* @param {Object} apiClient The ApiClient.
*/
init(apiClient) {
// Set ApiClient.
this.updateApiClient(apiClient);
// Get default player wrapper.
this.playerWrapper = this.playerFactory.getDefaultWrapper(this);
// Initialize components.
this.timeSyncCore.init(this);
this.playbackCore.init(this);
this.queueCore.init(this);
this.controller.init(this);
Events.on(this.timeSyncCore, 'time-sync-server-update', (event, timeOffset, ping) => {
// Report ping back to server.
if (this.syncEnabled) {
this.getApiClient().sendSyncPlayPing({
Ping: ping
});
}
});
}
/**
* Update active ApiClient.
* @param {Object} apiClient The ApiClient.
*/
updateApiClient(apiClient) {
if (!apiClient) {
throw new Error('ApiClient is null!');
}
this.apiClient = apiClient;
}
/**
* Gets the time sync core.
* @returns {TimeSyncCore} The time sync core.
*/
getTimeSyncCore() {
return this.timeSyncCore;
}
/**
* Gets the playback core.
* @returns {PlaybackCore} The playback core.
*/
getPlaybackCore() {
return this.playbackCore;
}
/**
* Gets the queue core.
* @returns {QueueCore} The queue core.
*/
getQueueCore() {
return this.queueCore;
}
/**
* Gets the controller used to manage SyncPlay playback.
* @returns {Controller} The controller.
*/
getController() {
return this.controller;
}
/**
* Gets the player wrapper used to control local playback.
* @returns {SyncPlayGenericPlayer} The player wrapper.
*/
getPlayerWrapper() {
return this.playerWrapper;
}
/**
* Gets the ApiClient used to communicate with the server.
* @returns {Object} The ApiClient.
*/
getApiClient() {
return this.apiClient;
}
/**
* Gets the last playback command, if any.
* @returns {Object} The playback command.
*/
getLastPlaybackCommand() {
return this.lastPlaybackCommand;
}
/**
* Called when the player changes.
*/
onPlayerChange(newPlayer) {
this.bindToPlayer(newPlayer);
}
/**
* Binds to the player's events.
* @param {Object} player The player.
*/
bindToPlayer(player) {
this.releaseCurrentPlayer();
if (!player) {
return;
}
this.playerWrapper.unbindFromPlayer();
this.currentPlayer = player;
this.playerWrapper = this.playerFactory.getWrapper(player, this);
if (this.isSyncPlayEnabled()) {
this.playerWrapper.bindToPlayer();
}
Events.trigger(this, 'playerchange', [this.currentPlayer]);
}
/**
* Removes the bindings from the current player's events.
*/
releaseCurrentPlayer() {
this.currentPlayer = null;
this.playerWrapper.unbindFromPlayer();
this.playerWrapper = this.playerFactory.getDefaultWrapper(this);
if (this.isSyncPlayEnabled()) {
this.playerWrapper.bindToPlayer();
}
Events.trigger(this, 'playerchange', [this.currentPlayer]);
}
/**
* Handles a group update from the server.
* @param {Object} cmd The group update.
* @param {Object} apiClient The ApiClient.
*/
processGroupUpdate(cmd, apiClient) {
switch (cmd.Type) {
case 'PlayQueue':
this.queueCore.updatePlayQueue(apiClient, cmd.Data);
break;
case 'UserJoined':
toast(globalize.translate('MessageSyncPlayUserJoined', cmd.Data));
if (!this.groupInfo.Participants) {
this.groupInfo.Participants = [cmd.Data];
} else {
this.groupInfo.Participants.push(cmd.Data);
}
break;
case 'UserLeft':
toast(globalize.translate('MessageSyncPlayUserLeft', cmd.Data));
if (this.groupInfo.Participants) {
this.groupInfo.Participants = this.groupInfo.Participants.filter((user) => user !== cmd.Data);
}
break;
case 'GroupJoined':
cmd.Data.LastUpdatedAt = new Date(cmd.Data.LastUpdatedAt);
this.enableSyncPlay(apiClient, cmd.Data, true);
break;
case 'SyncPlayIsDisabled':
toast(globalize.translate('MessageSyncPlayIsDisabled'));
break;
case 'NotInGroup':
case 'GroupLeft':
this.disableSyncPlay(true);
break;
case 'GroupUpdate':
cmd.Data.LastUpdatedAt = new Date(cmd.Data.LastUpdatedAt);
this.groupInfo = cmd.Data;
break;
case 'StateUpdate':
Events.trigger(this, 'group-state-update', [cmd.Data.State, cmd.Data.Reason]);
console.debug(`SyncPlay processGroupUpdate: state changed to ${cmd.Data.State} because ${cmd.Data.Reason}.`);
break;
case 'GroupDoesNotExist':
toast(globalize.translate('MessageSyncPlayGroupDoesNotExist'));
break;
case 'CreateGroupDenied':
toast(globalize.translate('MessageSyncPlayCreateGroupDenied'));
break;
case 'JoinGroupDenied':
toast(globalize.translate('MessageSyncPlayJoinGroupDenied'));
break;
case 'LibraryAccessDenied':
toast(globalize.translate('MessageSyncPlayLibraryAccessDenied'));
break;
default:
console.error(`SyncPlay processGroupUpdate: command ${cmd.Type} not recognised.`);
break;
}
}
/**
* Handles a playback command from the server.
* @param {Object} cmd The playback command.
*/
processCommand(cmd) {
if (cmd === null) return;
if (typeof cmd.When === 'string') {
cmd.When = new Date(cmd.When);
cmd.EmittedAt = new Date(cmd.EmittedAt);
cmd.PositionTicks = cmd.PositionTicks ? parseInt(cmd.PositionTicks) : null;
}
if (!this.isSyncPlayEnabled()) {
console.debug('SyncPlay processCommand: SyncPlay not enabled, ignoring command.', cmd);
return;
}
if (cmd.EmittedAt.getTime() < this.syncPlayEnabledAt.getTime()) {
console.debug('SyncPlay processCommand: ignoring old command.', cmd);
return;
}
if (!this.syncPlayReady) {
console.debug('SyncPlay processCommand: SyncPlay not ready, queued command.', cmd);
this.queuedCommand = cmd;
return;
}
this.lastPlaybackCommand = cmd;
if (!this.isPlaybackActive()) {
console.debug('SyncPlay processCommand: no active player!');
return;
}
// Make sure command matches playing item in playlist.
const playlistItemId = this.queueCore.getCurrentPlaylistItemId();
if (cmd.PlaylistItemId !== playlistItemId && cmd.Command !== 'Stop') {
console.error('SyncPlay processCommand: playlist item does not match!', cmd);
return;
}
console.log(`SyncPlay will ${cmd.Command} at ${cmd.When} (in ${cmd.When.getTime() - Date.now()} ms)${cmd.PositionTicks ? '' : ' from ' + cmd.PositionTicks}.`);
this.playbackCore.applyCommand(cmd);
}
/**
* Handles a group state change.
* @param {Object} update The group state update.
*/
processStateChange(update) {
if (update === null || update.State === null || update.Reason === null) return;
if (!this.isSyncPlayEnabled()) {
console.debug('SyncPlay processStateChange: SyncPlay not enabled, ignoring group state update.', update);
return;
}
Events.trigger(this, 'group-state-change', [update.State, update.Reason]);
}
/**
* Notifies server that this client is following group's playback.
* @param {Object} apiClient The ApiClient.
* @returns {Promise} A Promise fulfilled upon request completion.
*/
followGroupPlayback(apiClient) {
this.followingGroupPlayback = true;
return apiClient.requestSyncPlaySetIgnoreWait({
IgnoreWait: false
});
}
/**
* Starts this client's playback and loads the group's play queue.
* @param {Object} apiClient The ApiClient.
*/
resumeGroupPlayback(apiClient) {
this.followGroupPlayback(apiClient).then(() => {
this.queueCore.startPlayback(apiClient);
});
}
/**
* Stops this client's playback and notifies server to be ignored in group wait.
* @param {Object} apiClient The ApiClient.
*/
haltGroupPlayback(apiClient) {
this.followingGroupPlayback = false;
apiClient.requestSyncPlaySetIgnoreWait({
IgnoreWait: true
});
this.playbackCore.localStop();
}
/**
* Whether this client is following group playback.
* @returns {boolean} _true_ if client should play group's content, _false_ otherwise.
*/
isFollowingGroupPlayback() {
return this.followingGroupPlayback;
}
/**
* Enables SyncPlay.
* @param {Object} apiClient The ApiClient.
* @param {Object} groupInfo The joined group's info.
* @param {boolean} showMessage Display message.
*/
enableSyncPlay(apiClient, groupInfo, showMessage = false) {
if (this.isSyncPlayEnabled()) {
if (groupInfo.GroupId === this.groupInfo.GroupId) {
console.debug(`SyncPlay enableSyncPlay: group ${this.groupInfo.GroupId} already joined.`);
return;
} else {
console.warn(`SyncPlay enableSyncPlay: switching from group ${this.groupInfo.GroupId} to group ${groupInfo.GroupId}.`);
this.disableSyncPlay(false);
}
showMessage = false;
}
this.groupInfo = groupInfo;
this.syncPlayEnabledAt = groupInfo.LastUpdatedAt;
this.playerWrapper.bindToPlayer();
Events.trigger(this, 'enabled', [true]);
// Wait for time sync to be ready.
Helper.waitForEventOnce(this.timeSyncCore, 'time-sync-server-update').then(() => {
this.syncPlayReady = true;
this.processCommand(this.queuedCommand, apiClient);
this.queuedCommand = null;
});
this.syncPlayReady = false;
this.followingGroupPlayback = true;
this.timeSyncCore.forceUpdate();
if (showMessage) {
toast(globalize.translate('MessageSyncPlayEnabled'));
}
}
/**
* Disables SyncPlay.
* @param {boolean} showMessage Display message.
*/
disableSyncPlay(showMessage = false) {
this.syncPlayEnabledAt = null;
this.syncPlayReady = false;
this.followingGroupPlayback = true;
this.lastPlaybackCommand = null;
this.queuedCommand = null;
this.playbackCore.syncEnabled = false;
Events.trigger(this, 'enabled', [false]);
this.playerWrapper.unbindFromPlayer();
if (showMessage) {
toast(globalize.translate('MessageSyncPlayDisabled'));
}
}
/**
* Gets SyncPlay status.
* @returns {boolean} _true_ if user joined a group, _false_ otherwise.
*/
isSyncPlayEnabled() {
return this.syncPlayEnabledAt !== null;
}
/**
* Gets the group information.
* @returns {Object} The group information, null if SyncPlay is disabled.
*/
getGroupInfo() {
return this.groupInfo;
}
/**
* Gets SyncPlay stats.
* @returns {Object} The SyncPlay stats.
*/
getStats() {
return {
TimeSyncDevice: this.timeSyncCore.getActiveDeviceName(),
TimeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2),
PlaybackDiff: this.playbackCore.playbackDiffMillis.toFixed(2),
SyncMethod: this.syncMethod
};
}
/**
* Gets playback status.
* @returns {boolean} Whether a player is active.
*/
isPlaybackActive() {
return this.playerWrapper.isPlaybackActive();
}
/**
* Whether the player is remotely self-managed.
* @returns {boolean} _true_ if the player is remotely self-managed, _false_ otherwise.
*/
isRemote() {
return this.playerWrapper.isRemote();
}
/**
* Checks if playlist is empty.
* @returns {boolean} _true_ if playlist is empty, _false_ otherwise.
*/
isPlaylistEmpty() {
return this.queueCore.isPlaylistEmpty();
}
/**
* Checks if playback is unpaused.
* @returns {boolean} _true_ if media is playing, _false_ otherwise.
*/
isPlaying() {
if (!this.lastPlaybackCommand) {
return false;
} else {
return this.lastPlaybackCommand.Command === 'Unpause';
}
}
/**
* Emits an event to update the SyncPlay status icon.
*/
showSyncIcon(syncMethod) {
this.syncMethod = syncMethod;
Events.trigger(this, 'syncing', [true, this.syncMethod]);
}
/**
* Emits an event to clear the SyncPlay status icon.
*/
clearSyncIcon() {
this.syncMethod = 'None';
Events.trigger(this, 'syncing', [false, this.syncMethod]);
}
}
export default Manager;

View file

@ -1,608 +0,0 @@
/**
* Module that manages the playback of SyncPlay.
* @module components/syncPlay/core/PlaybackCore
*/
import { Events } from 'jellyfin-apiclient';
import browser from '../../../scripts/browser';
import { toBoolean, toFloat } from '../../../utils/string.ts';
import * as Helper from './Helper';
import { getSetting } from './Settings';
/**
* Class that manages the playback of SyncPlay.
*/
class PlaybackCore {
constructor() {
this.manager = null;
this.timeSyncCore = null;
this.syncEnabled = false;
this.playbackDiffMillis = 0; // Used for stats and remote time sync.
this.syncAttempts = 0;
this.lastSyncTime = new Date();
this.playerIsBuffering = false;
this.lastCommand = null; // Last scheduled playback command, might not be the latest one.
this.scheduledCommandTimeout = null;
this.syncTimeout = null;
this.loadPreferences();
}
/**
* Initializes the core.
* @param {Manager} syncPlayManager The SyncPlay manager.
*/
init(syncPlayManager) {
this.manager = syncPlayManager;
this.timeSyncCore = syncPlayManager.getTimeSyncCore();
Events.on(this.manager, 'settings-update', () => {
this.loadPreferences();
});
}
/**
* Loads preferences from saved settings.
*/
loadPreferences() {
// Minimum required delay for SpeedToSync to kick in, in milliseconds.
this.minDelaySpeedToSync = toFloat(getSetting('minDelaySpeedToSync'), 60.0);
// Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds.
this.maxDelaySpeedToSync = toFloat(getSetting('maxDelaySpeedToSync'), 3000.0);
// Time during which the playback is sped up, in milliseconds.
this.speedToSyncDuration = toFloat(getSetting('speedToSyncDuration'), 1000.0);
// Minimum required delay for SkipToSync to kick in, in milliseconds.
this.minDelaySkipToSync = toFloat(getSetting('minDelaySkipToSync'), 400.0);
// Whether SpeedToSync should be used.
this.useSpeedToSync = toBoolean(getSetting('useSpeedToSync'), true);
// Whether SkipToSync should be used.
this.useSkipToSync = toBoolean(getSetting('useSkipToSync'), true);
// Whether sync correction during playback is active.
this.enableSyncCorrection = toBoolean(getSetting('enableSyncCorrection'), !(browser.mobile || browser.iOS));
}
/**
* Called by player wrapper when playback starts.
*/
onPlaybackStart(player, state) {
Events.trigger(this.manager, 'playbackstart', [player, state]);
}
/**
* Called by player wrapper when playback stops.
*/
onPlaybackStop(stopInfo) {
this.lastCommand = null;
Events.trigger(this.manager, 'playbackstop', [stopInfo]);
}
/**
* Called by player wrapper when playback unpauses.
*/
onUnpause() {
Events.trigger(this.manager, 'unpause');
}
/**
* Called by player wrapper when playback pauses.
*/
onPause() {
Events.trigger(this.manager, 'pause');
}
/**
* Called by player wrapper on playback progress.
* @param {Object} event The time update event.
* @param {Object} timeUpdateData The time update data.
*/
onTimeUpdate(event, timeUpdateData) {
this.syncPlaybackTime(timeUpdateData);
Events.trigger(this.manager, 'timeupdate', [event, timeUpdateData]);
}
/**
* Called by player wrapper when player is ready to play.
*/
onReady() {
this.playerIsBuffering = false;
this.sendBufferingRequest(false);
Events.trigger(this.manager, 'ready');
}
/**
* Called by player wrapper when player is buffering.
*/
onBuffering() {
this.playerIsBuffering = true;
this.sendBufferingRequest(true);
Events.trigger(this.manager, 'buffering');
}
/**
* Sends a buffering request to the server.
* @param {boolean} isBuffering Whether this client is buffering or not.
*/
async sendBufferingRequest(isBuffering = true) {
const playerWrapper = this.manager.getPlayerWrapper();
const currentPosition = (playerWrapper.currentTimeAsync
? await playerWrapper.currentTimeAsync()
: playerWrapper.currentTime());
const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();
const currentTime = new Date();
const now = this.timeSyncCore.localDateToRemote(currentTime);
const playlistItemId = this.manager.getQueueCore().getCurrentPlaylistItemId();
const options = {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId
};
const apiClient = this.manager.getApiClient();
if (isBuffering) {
apiClient.requestSyncPlayBuffering(options);
} else {
apiClient.requestSyncPlayReady(options);
}
}
/**
* Gets playback buffering status.
* @returns {boolean} _true_ if player is buffering, _false_ otherwise.
*/
isBuffering() {
return this.playerIsBuffering;
}
/**
* Applies a command and checks the playback state if a duplicate command is received.
* @param {Object} command The playback command.
*/
async applyCommand(command) {
// Check if duplicate.
if (this.lastCommand &&
this.lastCommand.When.getTime() === command.When.getTime() &&
this.lastCommand.PositionTicks === command.PositionTicks &&
this.lastCommand.Command === command.Command &&
this.lastCommand.PlaylistItemId === command.PlaylistItemId
) {
// Duplicate command found, check playback state and correct if needed.
console.debug('SyncPlay applyCommand: duplicate command received!', command);
// Determine if past command or future one.
const currentTime = new Date();
const whenLocal = this.timeSyncCore.remoteDateToLocal(command.When);
if (whenLocal > currentTime) {
// Command should be already scheduled, not much we can do.
// TODO: should re-apply or just drop?
console.debug('SyncPlay applyCommand: command already scheduled.', command);
return;
} else {
// Check if playback state matches requested command.
const playerWrapper = this.manager.getPlayerWrapper();
const currentPositionTicks = Math.round((playerWrapper.currentTimeAsync
? await playerWrapper.currentTimeAsync()
: playerWrapper.currentTime()) * Helper.TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();
switch (command.Command) {
case 'Unpause':
// Check playback state only, as position ticks will be corrected by sync.
if (!isPlaying) {
this.scheduleUnpause(command.When, command.PositionTicks);
}
break;
case 'Pause':
// FIXME: check range instead of fixed value for ticks.
if (isPlaying || currentPositionTicks !== command.PositionTicks) {
this.schedulePause(command.When, command.PositionTicks);
}
break;
case 'Stop':
if (isPlaying) {
this.scheduleStop(command.When);
}
break;
case 'Seek':
// During seek, playback is paused.
// FIXME: check range instead of fixed value for ticks.
if (isPlaying || currentPositionTicks !== command.PositionTicks) {
// Account for player imperfections, we got half a second of tollerance we can play with
// (the server tollerates a range of values when client reports that is ready).
const rangeWidth = 100; // In milliseconds.
const randomOffsetTicks = Math.round((Math.random() - 0.5) * rangeWidth) * Helper.TicksPerMillisecond;
this.scheduleSeek(command.When, command.PositionTicks + randomOffsetTicks);
console.debug('SyncPlay applyCommand: adding random offset to force seek:', randomOffsetTicks, command);
} else {
// All done, I guess?
this.sendBufferingRequest(false);
}
break;
default:
console.error('SyncPlay applyCommand: command is not recognised:', command);
break;
}
// All done.
return;
}
}
// Applying command.
this.lastCommand = command;
// Ignore if remote player has local SyncPlay manager.
if (this.manager.isRemote()) {
return;
}
switch (command.Command) {
case 'Unpause':
this.scheduleUnpause(command.When, command.PositionTicks);
break;
case 'Pause':
this.schedulePause(command.When, command.PositionTicks);
break;
case 'Stop':
this.scheduleStop(command.When);
break;
case 'Seek':
this.scheduleSeek(command.When, command.PositionTicks);
break;
default:
console.error('SyncPlay applyCommand: command is not recognised:', command);
break;
}
}
/**
* Schedules a resume playback on the player at the specified clock time.
* @param {Date} playAtTime The server's UTC time at which to resume playback.
* @param {number} positionTicks The PositionTicks from where to resume.
*/
async scheduleUnpause(playAtTime, positionTicks) {
this.clearScheduledCommand();
const enableSyncTimeout = this.maxDelaySpeedToSync / 2.0;
const currentTime = new Date();
const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
const playerWrapper = this.manager.getPlayerWrapper();
const currentPositionTicks = (playerWrapper.currentTimeAsync
? await playerWrapper.currentTimeAsync()
: playerWrapper.currentTime()) * Helper.TicksPerMillisecond;
if (playAtTimeLocal > currentTime) {
const playTimeout = playAtTimeLocal - currentTime;
// Seek only if delay is noticeable.
if ((currentPositionTicks - positionTicks) > this.minDelaySkipToSync * Helper.TicksPerMillisecond) {
this.localSeek(positionTicks);
}
this.scheduledCommandTimeout = setTimeout(() => {
this.localUnpause();
Events.trigger(this.manager, 'notify-osd', ['unpause']);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, enableSyncTimeout);
}, playTimeout);
console.debug('Scheduled unpause in', playTimeout / 1000.0, 'seconds.');
} else {
// Group playback already started.
const serverPositionTicks = this.estimateCurrentTicks(positionTicks, playAtTime);
Helper.waitForEventOnce(this.manager, 'unpause').then(() => {
this.localSeek(serverPositionTicks);
});
this.localUnpause();
setTimeout(() => {
Events.trigger(this.manager, 'notify-osd', ['unpause']);
}, 100);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, enableSyncTimeout);
console.debug(`SyncPlay scheduleUnpause: unpause now from ${serverPositionTicks} (was at ${currentPositionTicks}).`);
}
}
/**
* Schedules a pause playback on the player at the specified clock time.
* @param {Date} pauseAtTime The server's UTC time at which to pause playback.
* @param {number} positionTicks The PositionTicks where player will be paused.
*/
schedulePause(pauseAtTime, positionTicks) {
this.clearScheduledCommand();
const currentTime = new Date();
const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime);
const callback = () => {
Helper.waitForEventOnce(this.manager, 'pause', Helper.WaitForPlayerEventTimeout).then(() => {
this.localSeek(positionTicks);
}).catch(() => {
// Player was already paused, seeking.
this.localSeek(positionTicks);
});
this.localPause();
};
if (pauseAtTimeLocal > currentTime) {
const pauseTimeout = pauseAtTimeLocal - currentTime;
this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout);
console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.');
} else {
callback();
console.debug('SyncPlay schedulePause: now.');
}
}
/**
* Schedules a stop playback on the player at the specified clock time.
* @param {Date} stopAtTime The server's UTC time at which to stop playback.
*/
scheduleStop(stopAtTime) {
this.clearScheduledCommand();
const currentTime = new Date();
const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime);
const callback = () => {
this.localStop();
};
if (stopAtTimeLocal > currentTime) {
const stopTimeout = stopAtTimeLocal - currentTime;
this.scheduledCommandTimeout = setTimeout(callback, stopTimeout);
console.debug('Scheduled stop in', stopTimeout / 1000.0, 'seconds.');
} else {
callback();
console.debug('SyncPlay scheduleStop: now.');
}
}
/**
* Schedules a seek playback on the player at the specified clock time.
* @param {Date} seekAtTime The server's UTC time at which to seek playback.
* @param {number} positionTicks The PositionTicks where player will be seeked.
*/
scheduleSeek(seekAtTime, positionTicks) {
this.clearScheduledCommand();
const currentTime = new Date();
const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime);
const callback = () => {
this.localUnpause();
this.localSeek(positionTicks);
Helper.waitForEventOnce(this.manager, 'ready', Helper.WaitForEventDefaultTimeout).then(() => {
this.localPause();
this.sendBufferingRequest(false);
}).catch((error) => {
console.error(`Timed out while waiting for 'ready' event! Seeking to ${positionTicks}.`, error);
this.localSeek(positionTicks);
});
};
if (seekAtTimeLocal > currentTime) {
const seekTimeout = seekAtTimeLocal - currentTime;
this.scheduledCommandTimeout = setTimeout(callback, seekTimeout);
console.debug('Scheduled seek in', seekTimeout / 1000.0, 'seconds.');
} else {
callback();
console.debug('SyncPlay scheduleSeek: now.');
}
}
/**
* Clears the current scheduled command.
*/
clearScheduledCommand() {
clearTimeout(this.scheduledCommandTimeout);
clearTimeout(this.syncTimeout);
this.syncEnabled = false;
const playerWrapper = this.manager.getPlayerWrapper();
if (playerWrapper.hasPlaybackRate()) {
playerWrapper.setPlaybackRate(1.0);
}
this.manager.clearSyncIcon();
}
/**
* Unpauses the local player.
*/
localUnpause() {
// Ignore command when no player is active.
if (!this.manager.isPlaybackActive()) {
console.debug('SyncPlay localUnpause: no active player!');
return;
}
const playerWrapper = this.manager.getPlayerWrapper();
return playerWrapper.localUnpause();
}
/**
* Pauses the local player.
*/
localPause() {
// Ignore command when no player is active.
if (!this.manager.isPlaybackActive()) {
console.debug('SyncPlay localPause: no active player!');
return;
}
const playerWrapper = this.manager.getPlayerWrapper();
return playerWrapper.localPause();
}
/**
* Seeks the local player.
*/
localSeek(positionTicks) {
// Ignore command when no player is active.
if (!this.manager.isPlaybackActive()) {
console.debug('SyncPlay localSeek: no active player!');
return;
}
const playerWrapper = this.manager.getPlayerWrapper();
return playerWrapper.localSeek(positionTicks);
}
/**
* Stops the local player.
*/
localStop() {
// Ignore command when no player is active.
if (!this.manager.isPlaybackActive()) {
console.debug('SyncPlay localStop: no active player!');
return;
}
const playerWrapper = this.manager.getPlayerWrapper();
return playerWrapper.localStop();
}
/**
* Estimates current value for ticks given a past state.
* @param {number} ticks The value of the ticks.
* @param {Date} when The point in time for the value of the ticks.
* @param {Date} currentTime The current time, optional.
*/
estimateCurrentTicks(ticks, when, currentTime = new Date()) {
const remoteTime = this.timeSyncCore.localDateToRemote(currentTime);
return ticks + (remoteTime.getTime() - when.getTime()) * Helper.TicksPerMillisecond;
}
/**
* Attempts to sync playback time with estimated server time (or selected device for time sync).
*
* When sync is enabled, the following will be checked:
* - check if local playback time is close enough to the server playback time;
* - playback diff (distance from estimated server playback time) is aligned with selected device for time sync.
* If playback diff exceeds some set thresholds, then a playback time sync will be attempted.
* Two strategies of syncing are available:
* - SpeedToSync: speeds up the media for some time to catch up (default is one second)
* - SkipToSync: seeks the media to the estimated correct time
* SpeedToSync aims to reduce the delay as much as possible, whereas SkipToSync is less pretentious.
* @param {Object} timeUpdateData The time update data that contains the current time as date and the current position in milliseconds.
*/
syncPlaybackTime(timeUpdateData) {
// See comments in constants section for more info.
const syncMethodThreshold = this.maxDelaySpeedToSync;
let speedToSyncTime = this.speedToSyncDuration;
// Ignore sync when no player is active.
if (!this.manager.isPlaybackActive()) {
console.debug('SyncPlay syncPlaybackTime: no active player!');
return;
}
// Attempt to sync only when media is playing.
const { lastCommand } = this;
if (!lastCommand || lastCommand.Command !== 'Unpause' || this.isBuffering()) return;
// Avoid spoilers by making sure that command item matches current playlist item.
// This check is needed when switching from one item to another.
const queueCore = this.manager.getQueueCore();
const currentPlaylistItem = queueCore.getCurrentPlaylistItemId();
if (lastCommand.PlaylistItemId !== currentPlaylistItem) return;
const { currentTime, currentPosition } = timeUpdateData;
// Get current PositionTicks.
const currentPositionTicks = currentPosition * Helper.TicksPerMillisecond;
// Estimate PositionTicks on server.
const serverPositionTicks = this.estimateCurrentTicks(lastCommand.PositionTicks, lastCommand.When, currentTime);
// Measure delay that needs to be recovered.
// Diff might be caused by the player internally starting the playback.
const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond;
// Notify update for playback sync.
this.playbackDiffMillis = diffMillis;
Events.trigger(this.manager, 'playback-diff', [this.playbackDiffMillis]);
// Avoid overloading the browser.
const elapsed = currentTime - this.lastSyncTime;
if (elapsed < syncMethodThreshold / 2) return;
this.lastSyncTime = currentTime;
const playerWrapper = this.manager.getPlayerWrapper();
if (this.syncEnabled && this.enableSyncCorrection) {
const absDiffMillis = Math.abs(diffMillis);
// TODO: SpeedToSync sounds bad on songs.
// TODO: SpeedToSync is failing on Safari (Mojave); even if playbackRate is supported, some delay seems to exist.
// TODO: both SpeedToSync and SpeedToSync seem to have a hard time keeping up on Android Chrome as well.
if (playerWrapper.hasPlaybackRate() && this.useSpeedToSync && absDiffMillis >= this.minDelaySpeedToSync && absDiffMillis < this.maxDelaySpeedToSync) {
// Fix negative speed when client is ahead of time more than speedToSyncTime.
const MinSpeed = 0.2;
if (diffMillis <= -speedToSyncTime * MinSpeed) {
speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed);
}
// SpeedToSync strategy.
const speed = 1 + diffMillis / speedToSyncTime;
if (speed <= 0) {
console.error('SyncPlay error: speed should not be negative!', speed, diffMillis, speedToSyncTime);
}
playerWrapper.setPlaybackRate(speed);
this.syncEnabled = false;
this.syncAttempts++;
this.manager.showSyncIcon(`SpeedToSync (x${speed.toFixed(2)})`);
this.syncTimeout = setTimeout(() => {
playerWrapper.setPlaybackRate(1.0);
this.syncEnabled = true;
this.manager.clearSyncIcon();
}, speedToSyncTime);
console.log('SyncPlay SpeedToSync', speed);
} else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) {
// SkipToSync strategy.
this.localSeek(serverPositionTicks);
this.syncEnabled = false;
this.syncAttempts++;
this.manager.showSyncIcon(`SkipToSync (${this.syncAttempts})`);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
this.manager.clearSyncIcon();
}, syncMethodThreshold / 2);
console.log('SyncPlay SkipToSync', serverPositionTicks);
} else {
// Playback is synced.
if (this.syncAttempts > 0) {
console.debug('Playback has been synced after', this.syncAttempts, 'attempts.');
}
this.syncAttempts = 0;
}
}
}
}
export default PlaybackCore;

View file

@ -1,375 +0,0 @@
/**
* Module that manages the queue of SyncPlay.
* @module components/syncPlay/core/QueueCore
*/
import globalize from '../../../scripts/globalize';
import toast from '../../toast/toast';
import * as Helper from './Helper';
/**
* Class that manages the queue of SyncPlay.
*/
class QueueCore {
constructor() {
this.manager = null;
this.lastPlayQueueUpdate = null;
this.playlist = [];
}
/**
* Initializes the core.
* @param {Manager} syncPlayManager The SyncPlay manager.
*/
init(syncPlayManager) {
this.manager = syncPlayManager;
}
/**
* Handles the change in the play queue.
* @param {Object} apiClient The ApiClient.
* @param {Object} newPlayQueue The new play queue.
*/
updatePlayQueue(apiClient, newPlayQueue) {
newPlayQueue.LastUpdate = new Date(newPlayQueue.LastUpdate);
if (newPlayQueue.LastUpdate.getTime() <= this.getLastUpdateTime()) {
console.debug('SyncPlay updatePlayQueue: ignoring old update', newPlayQueue);
return;
}
console.debug('SyncPlay updatePlayQueue:', newPlayQueue);
const serverId = apiClient.serverInfo().Id;
this.onPlayQueueUpdate(apiClient, newPlayQueue, serverId).then((previous) => {
if (newPlayQueue.LastUpdate.getTime() < this.getLastUpdateTime()) {
console.warn('SyncPlay updatePlayQueue: trying to apply old update.', newPlayQueue);
throw new Error('Trying to apply old update');
}
// Ignore if remote player is self-managed (has own SyncPlay manager running).
if (this.manager.isRemote()) {
console.warn('SyncPlay updatePlayQueue: remote player has own SyncPlay manager.');
return;
}
const playerWrapper = this.manager.getPlayerWrapper();
switch (newPlayQueue.Reason) {
case 'NewPlaylist': {
if (!this.manager.isFollowingGroupPlayback()) {
this.manager.followGroupPlayback(apiClient).then(() => {
this.startPlayback(apiClient);
});
} else {
this.startPlayback(apiClient);
}
break;
}
case 'SetCurrentItem':
case 'NextItem':
case 'PreviousItem': {
playerWrapper.onQueueUpdate();
const playlistItemId = this.getCurrentPlaylistItemId();
this.setCurrentPlaylistItem(apiClient, playlistItemId);
break;
}
case 'RemoveItems': {
playerWrapper.onQueueUpdate();
const index = previous.playQueueUpdate.PlayingItemIndex;
const oldPlaylistItemId = index === -1 ? null : previous.playlist[index].PlaylistItemId;
const playlistItemId = this.getCurrentPlaylistItemId();
if (oldPlaylistItemId !== playlistItemId) {
this.setCurrentPlaylistItem(apiClient, playlistItemId);
}
break;
}
case 'MoveItem':
case 'Queue':
case 'QueueNext': {
playerWrapper.onQueueUpdate();
break;
}
case 'RepeatMode':
playerWrapper.localSetRepeatMode(this.getRepeatMode());
break;
case 'ShuffleMode':
playerWrapper.localSetQueueShuffleMode(this.getShuffleMode());
break;
default:
console.error('SyncPlay updatePlayQueue: unknown reason for update:', newPlayQueue.Reason);
break;
}
}).catch((error) => {
console.warn('SyncPlay updatePlayQueue:', error);
});
}
/**
* Called when a play queue update needs to be applied.
* @param {Object} apiClient The ApiClient.
* @param {Object} playQueueUpdate The play queue update.
* @param {string} serverId The server identifier.
* @returns {Promise} A promise that gets resolved when update is applied.
*/
onPlayQueueUpdate(apiClient, playQueueUpdate, serverId) {
const oldPlayQueueUpdate = this.lastPlayQueueUpdate;
const oldPlaylist = this.playlist;
const itemIds = playQueueUpdate.Playlist.map(queueItem => queueItem.ItemId);
if (!itemIds.length) {
if (this.lastPlayQueueUpdate && playQueueUpdate.LastUpdate.getTime() <= this.getLastUpdateTime()) {
return Promise.reject('Trying to apply old update');
}
this.lastPlayQueueUpdate = playQueueUpdate;
this.playlist = [];
return Promise.resolve({
playQueueUpdate: oldPlayQueueUpdate,
playlist: oldPlaylist
});
}
return Helper.getItemsForPlayback(apiClient, {
Ids: itemIds.join(',')
}).then((result) => {
return Helper.translateItemsForPlayback(apiClient, result.Items, {
ids: itemIds,
serverId: serverId
}).then((items) => {
if (this.lastPlayQueueUpdate && playQueueUpdate.LastUpdate.getTime() <= this.getLastUpdateTime()) {
throw new Error('Trying to apply old update');
}
for (let i = 0; i < items.length; i++) {
items[i].PlaylistItemId = playQueueUpdate.Playlist[i].PlaylistItemId;
}
this.lastPlayQueueUpdate = playQueueUpdate;
this.playlist = items;
return {
playQueueUpdate: oldPlayQueueUpdate,
playlist: oldPlaylist
};
});
});
}
/**
* Sends a SyncPlayBuffering request on playback start.
* @param {Object} apiClient The ApiClient.
* @param {string} origin The origin of the wait call, used for debug.
*/
scheduleReadyRequestOnPlaybackStart(apiClient, origin) {
Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(async () => {
console.debug('SyncPlay scheduleReadyRequestOnPlaybackStart: local pause and notify server.');
const playerWrapper = this.manager.getPlayerWrapper();
playerWrapper.localPause();
const currentTime = new Date();
const now = this.manager.timeSyncCore.localDateToRemote(currentTime);
const currentPosition = (playerWrapper.currentTimeAsync
? await playerWrapper.currentTimeAsync()
: playerWrapper.currentTime());
const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();
apiClient.requestSyncPlayReady({
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: this.getCurrentPlaylistItemId()
});
}).catch((error) => {
console.error('Error while waiting for `playbackstart` event!', origin, error);
if (!this.manager.isSyncPlayEnabled()) {
toast(globalize.translate('MessageSyncPlayErrorMedia'));
}
this.manager.haltGroupPlayback(apiClient);
return;
});
}
/**
* Prepares this client for playback by loading the group's content.
* @param {Object} apiClient The ApiClient.
*/
startPlayback(apiClient) {
if (!this.manager.isFollowingGroupPlayback()) {
console.debug('SyncPlay startPlayback: ignoring, not following playback.');
return Promise.reject();
}
if (this.isPlaylistEmpty()) {
console.debug('SyncPlay startPlayback: empty playlist.');
return;
}
// Estimate start position ticks from last playback command, if available.
const playbackCommand = this.manager.getLastPlaybackCommand();
let startPositionTicks = 0;
if (playbackCommand && playbackCommand.EmittedAt.getTime() >= this.getLastUpdateTime()) {
// Prefer playback commands as they're more frequent (and also because playback position is PlaybackCore's concern).
startPositionTicks = this.manager.getPlaybackCore().estimateCurrentTicks(playbackCommand.PositionTicks, playbackCommand.When);
} else {
// A PlayQueueUpdate is emited only on queue changes so it's less reliable for playback position syncing.
const oldStartPositionTicks = this.getStartPositionTicks();
const lastQueueUpdateDate = this.getLastUpdate();
startPositionTicks = this.manager.getPlaybackCore().estimateCurrentTicks(oldStartPositionTicks, lastQueueUpdateDate);
}
const serverId = apiClient.serverInfo().Id;
const playerWrapper = this.manager.getPlayerWrapper();
playerWrapper.localPlay({
ids: this.getPlaylistAsItemIds(),
startPositionTicks: startPositionTicks,
startIndex: this.getCurrentPlaylistIndex(),
serverId: serverId
}).then(() => {
this.scheduleReadyRequestOnPlaybackStart(apiClient, 'startPlayback');
}).catch((error) => {
console.error(error);
toast(globalize.translate('MessageSyncPlayErrorMedia'));
});
}
/**
* Sets the current playing item.
* @param {Object} apiClient The ApiClient.
* @param {string} playlistItemId The playlist id of the item to play.
*/
setCurrentPlaylistItem(apiClient, playlistItemId) {
if (!this.manager.isFollowingGroupPlayback()) {
console.debug('SyncPlay setCurrentPlaylistItem: ignoring, not following playback.');
return;
}
this.scheduleReadyRequestOnPlaybackStart(apiClient, 'setCurrentPlaylistItem');
const playerWrapper = this.manager.getPlayerWrapper();
playerWrapper.localSetCurrentPlaylistItem(playlistItemId);
}
/**
* Gets the index of the current playing item.
* @returns {number} The index of the playing item.
*/
getCurrentPlaylistIndex() {
if (this.lastPlayQueueUpdate) {
return this.lastPlayQueueUpdate.PlayingItemIndex;
} else {
return -1;
}
}
/**
* Gets the playlist item id of the playing item.
* @returns {string} The playlist item id.
*/
getCurrentPlaylistItemId() {
if (this.lastPlayQueueUpdate) {
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
return index === -1 ? null : this.playlist[index].PlaylistItemId;
} else {
return null;
}
}
/**
* Gets a copy of the playlist.
* @returns {Array} The playlist.
*/
getPlaylist() {
return this.playlist.slice(0);
}
/**
* Checks if playlist is empty.
* @returns {boolean} _true_ if playlist is empty, _false_ otherwise.
*/
isPlaylistEmpty() {
return this.playlist.length === 0;
}
/**
* Gets the last update time as date, if any.
* @returns {Date} The date.
*/
getLastUpdate() {
if (this.lastPlayQueueUpdate) {
return this.lastPlayQueueUpdate.LastUpdate;
} else {
return null;
}
}
/**
* Gets the time of when the queue has been updated.
* @returns {number} The last update time.
*/
getLastUpdateTime() {
if (this.lastPlayQueueUpdate) {
return this.lastPlayQueueUpdate.LastUpdate.getTime();
} else {
return 0;
}
}
/**
* Gets the last reported start position ticks of playing item.
* @returns {number} The start position ticks.
*/
getStartPositionTicks() {
if (this.lastPlayQueueUpdate) {
return this.lastPlayQueueUpdate.StartPositionTicks;
} else {
return 0;
}
}
/**
* Gets the list of item identifiers in the playlist.
* @returns {Array} The list of items.
*/
getPlaylistAsItemIds() {
if (this.lastPlayQueueUpdate) {
return this.lastPlayQueueUpdate.Playlist.map(queueItem => queueItem.ItemId);
} else {
return [];
}
}
/**
* Gets the repeat mode.
* @returns {string} The repeat mode.
*/
getRepeatMode() {
if (this.lastPlayQueueUpdate) {
return this.lastPlayQueueUpdate.RepeatMode;
} else {
return 'Sorted';
}
}
/**
* Gets the shuffle mode.
* @returns {string} The shuffle mode.
*/
getShuffleMode() {
if (this.lastPlayQueueUpdate) {
return this.lastPlayQueueUpdate.ShuffleMode;
} else {
return 'RepeatNone';
}
}
}
export default QueueCore;

View file

@ -1,28 +0,0 @@
/**
* Module that manages SyncPlay settings.
* @module components/syncPlay/core/Settings
*/
import appSettings from '../../../scripts/settings/appSettings';
/**
* Prefix used when saving SyncPlay settings.
*/
const PREFIX = 'syncPlay';
/**
* Gets the value of a setting.
* @param {string} name The name of the setting.
* @returns {string} The value.
*/
export function getSetting(name) {
return appSettings.get(name, PREFIX);
}
/**
* Sets the value of a setting. Triggers an update if the new value differs from the old one.
* @param {string} name The name of the setting.
* @param {Object} value The value of the setting.
*/
export function setSetting(name, value) {
return appSettings.set(name, value, PREFIX);
}

View file

@ -1,16 +0,0 @@
import * as Helper from './Helper';
import ManagerClass from './Manager';
import PlayerFactoryClass from './players/PlayerFactory';
import GenericPlayer from './players/GenericPlayer';
const PlayerFactory = new PlayerFactoryClass();
const Manager = new ManagerClass(PlayerFactory);
export default {
Helper,
Manager,
PlayerFactory,
Players: {
GenericPlayer
}
};

View file

@ -1,316 +0,0 @@
/**
* Module that translates events from a player to SyncPlay events.
* @module components/syncPlay/core/players/GenericPlayer
*/
import { Events } from 'jellyfin-apiclient';
/**
* Class that translates events from a player to SyncPlay events.
*/
class GenericPlayer {
static type = 'generic';
constructor(player, syncPlayManager) {
this.player = player;
this.manager = syncPlayManager;
this.playbackCore = syncPlayManager.getPlaybackCore();
this.queueCore = syncPlayManager.getQueueCore();
this.bound = false;
}
/**
* Binds to the player's events.
*/
bindToPlayer() {
if (this.bound) {
return;
}
this.localBindToPlayer();
this.bound = true;
}
/**
* Binds to the player's events. Overriden.
*/
localBindToPlayer() {
throw new Error('Override this method!');
}
/**
* Removes the bindings from the player's events.
*/
unbindFromPlayer() {
if (!this.bound) {
return;
}
this.localUnbindFromPlayer();
this.bound = false;
}
/**
* Removes the bindings from the player's events. Overriden.
*/
localUnbindFromPlayer() {
throw new Error('Override this method!');
}
/**
* Called when playback starts.
*/
onPlaybackStart(player, state) {
this.playbackCore.onPlaybackStart(player, state);
Events.trigger(this, 'playbackstart', [player, state]);
}
/**
* Called when playback stops.
*/
onPlaybackStop(stopInfo) {
this.playbackCore.onPlaybackStop(stopInfo);
Events.trigger(this, 'playbackstop', [stopInfo]);
}
/**
* Called when playback unpauses.
*/
onUnpause() {
this.playbackCore.onUnpause();
Events.trigger(this, 'unpause', [this.currentPlayer]);
}
/**
* Called when playback pauses.
*/
onPause() {
this.playbackCore.onPause();
Events.trigger(this, 'pause', [this.currentPlayer]);
}
/**
* Called on playback progress.
* @param {Object} event The time update event.
* @param {Object} timeUpdateData The time update data.
*/
onTimeUpdate(event, timeUpdateData) {
this.playbackCore.onTimeUpdate(event, timeUpdateData);
Events.trigger(this, 'timeupdate', [event, timeUpdateData]);
}
/**
* Called when player is ready to resume playback.
*/
onReady() {
this.playbackCore.onReady();
Events.trigger(this, 'ready');
}
/**
* Called when player is buffering.
*/
onBuffering() {
this.playbackCore.onBuffering();
Events.trigger(this, 'buffering');
}
/**
* Called when changes are made to the play queue.
*/
onQueueUpdate() {
// Do nothing.
}
/**
* Gets player status.
* @returns {boolean} Whether the player has some media loaded.
*/
isPlaybackActive() {
return false;
}
/**
* Gets playback status.
* @returns {boolean} Whether the playback is unpaused.
*/
isPlaying() {
return false;
}
/**
* Gets playback position.
* @returns {number} The player position, in milliseconds.
*/
currentTime() {
return 0;
}
/**
* Checks if player has playback rate support.
* @returns {boolean} _true _ if playback rate is supported, false otherwise.
*/
hasPlaybackRate() {
return false;
}
/**
* Sets the playback rate, if supported.
* @param {number} value The playback rate.
*/
// eslint-disable-next-line no-unused-vars
setPlaybackRate(value) {
// Do nothing.
}
/**
* Gets the playback rate.
* @returns {number} The playback rate.
*/
getPlaybackRate() {
return 1.0;
}
/**
* Checks if player is remotely self-managed.
* @returns {boolean} _true_ if the player is remotely self-managed, _false_ otherwise.
*/
isRemote() {
return false;
}
/**
* Unpauses the player.
*/
localUnpause() {
// Override
}
/**
* Pauses the player.
*/
localPause() {
// Override
}
/**
* Seeks the player to the specified position.
* @param {number} positionTicks The new position.
*/
// eslint-disable-next-line no-unused-vars
localSeek(positionTicks) {
// Override
}
/**
* Stops the player.
*/
localStop() {
// Override
}
/**
* Sends a command to the player.
* @param {Object} command The command.
*/
// eslint-disable-next-line no-unused-vars
localSendCommand(command) {
// Override
}
/**
* Starts playback.
* @param {Object} options Playback data.
*/
// eslint-disable-next-line no-unused-vars
localPlay(options) {
// Override
}
/**
* Sets playing item from playlist.
* @param {string} playlistItemId The item to play.
*/
// eslint-disable-next-line no-unused-vars
localSetCurrentPlaylistItem(playlistItemId) {
// Override
}
/**
* Removes items from playlist.
* @param {Array} playlistItemIds The items to remove.
*/
// eslint-disable-next-line no-unused-vars
localRemoveFromPlaylist(playlistItemIds) {
// Override
}
/**
* Moves an item in the playlist.
* @param {string} playlistItemId The item to move.
* @param {number} newIndex The new position.
*/
// eslint-disable-next-line no-unused-vars
localMovePlaylistItem(playlistItemId, newIndex) {
// Override
}
/**
* Queues in the playlist.
* @param {Object} options Queue data.
*/
// eslint-disable-next-line no-unused-vars
localQueue(options) {
// Override
}
/**
* Queues after the playing item in the playlist.
* @param {Object} options Queue data.
*/
// eslint-disable-next-line no-unused-vars
localQueueNext(options) {
// Override
}
/**
* Picks next item in playlist.
*/
localNextItem() {
// Override
}
/**
* Picks previous item in playlist.
*/
localPreviousItem() {
// Override
}
/**
* Sets repeat mode.
* @param {string} value The repeat mode.
*/
// eslint-disable-next-line no-unused-vars
localSetRepeatMode(value) {
// Override
}
/**
* Sets shuffle mode.
* @param {string} value The shuffle mode.
*/
// eslint-disable-next-line no-unused-vars
localSetQueueShuffleMode(value) {
// Override
}
/**
* Toggles shuffle mode.
*/
localToggleQueueShuffleMode() {
// Override
}
}
export default GenericPlayer;

View file

@ -1,73 +0,0 @@
/**
* Module that creates wrappers for known players.
* @module components/syncPlay/core/players/PlayerFactory
*/
import GenericPlayer from './GenericPlayer';
/**
* Class that creates wrappers for known players.
*/
class PlayerFactory {
constructor() {
this.wrappers = {};
this.DefaultWrapper = GenericPlayer;
}
/**
* Registers a wrapper to the list of players that can be managed.
* @param {GenericPlayer} wrapperClass The wrapper to register.
*/
registerWrapper(wrapperClass) {
console.debug('SyncPlay WrapperFactory registerWrapper:', wrapperClass.type);
this.wrappers[wrapperClass.type] = wrapperClass;
}
/**
* Sets the default player wrapper.
* @param {GenericPlayer} wrapperClass The wrapper.
*/
setDefaultWrapper(wrapperClass) {
console.debug('SyncPlay WrapperFactory setDefaultWrapper:', wrapperClass.type);
this.DefaultWrapper = wrapperClass;
}
/**
* Gets a player wrapper that manages the given player. Default wrapper is used for unknown players.
* @param {Object} player The player to handle.
* @param {SyncPlayManager} syncPlayManager The SyncPlay manager.
* @returns The player wrapper.
*/
getWrapper(player, syncPlayManager) {
if (!player) {
console.debug('SyncPlay WrapperFactory getWrapper: using default wrapper.');
return this.getDefaultWrapper(syncPlayManager);
}
const playerId = player.syncPlayWrapAs || player.id;
console.debug('SyncPlay WrapperFactory getWrapper:', playerId);
const Wrapper = this.wrappers[playerId];
if (Wrapper) {
return new Wrapper(player, syncPlayManager);
}
console.debug(`SyncPlay WrapperFactory getWrapper: unknown player ${playerId}, using default wrapper.`);
return this.getDefaultWrapper(syncPlayManager);
}
/**
* Gets the default player wrapper.
* @param {SyncPlayManager} syncPlayManager The SyncPlay manager.
* @returns The default player wrapper.
*/
getDefaultWrapper(syncPlayManager) {
if (this.DefaultWrapper) {
return new this.DefaultWrapper(null, syncPlayManager);
} else {
return null;
}
}
}
export default PlayerFactory;

View file

@ -1,220 +0,0 @@
/**
* Module that manages time syncing with another device.
* @module components/syncPlay/core/timeSync/TimeSync
*/
import { Events } from 'jellyfin-apiclient';
/**
* 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 Remote's timestamp of the request reception
* @param {Date} responseSent Remote's timestamp of the response transmission
* @param {Date} responseReceived Client's timestamp of the response reception
*/
constructor(requestSent, requestReceived, responseSent, responseReceived) {
this.requestSent = requestSent.getTime();
this.requestReceived = requestReceived.getTime();
this.responseSent = responseSent.getTime();
this.responseReceived = responseReceived.getTime();
}
/**
* Time offset from remote entity, in milliseconds.
*/
getOffset() {
return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2;
}
/**
* Get round-trip delay, in milliseconds.
*/
getDelay() {
return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived);
}
/**
* Get ping time, in milliseconds.
*/
getPing() {
return this.getDelay() / 2;
}
}
/**
* Class that manages time syncing with remote entity.
*/
class TimeSync {
constructor(syncPlayManager) {
this.manager = syncPlayManager;
this.pingStop = true;
this.pollingInterval = PollingIntervalGreedy;
this.poller = null;
this.pings = 0; // number of pings
this.measurement = null; // current time sync
this.measurements = [];
}
/**
* Gets status of time sync.
* @returns {boolean} _true_ if a measurement has been done, _false_ otherwise.
*/
isReady() {
return !!this.measurement;
}
/**
* Gets time offset with remote entity, in milliseconds.
* @returns {number} The time offset.
*/
getTimeOffset() {
return this.measurement ? this.measurement.getOffset() : 0;
}
/**
* Gets ping time to remote entity, in milliseconds.
* @returns {number} The ping time.
*/
getPing() {
return this.measurement ? this.measurement.getPing() : 0;
}
/**
* Updates time offset between remote entity and local entity.
* @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 remote entity. Triggers time offset update.
* @returns {Promise} Resolves on request success.
*/
requestPing() {
console.warn('SyncPlay TimeSync requestPing: override this method!');
return Promise.reject('Not implemented.');
}
/**
* Poller for ping requests.
*/
internalRequestPing() {
if (!this.poller && !this.pingStop) {
this.poller = setTimeout(() => {
this.poller = null;
this.requestPing()
.then((result) => this.onPingResponseCallback(result))
.catch((error) => this.onPingRequestErrorCallback(error))
.finally(() => this.internalRequestPing());
}, this.pollingInterval);
}
}
/**
* Handles a successful ping request.
* @param {Object} result The ping result.
*/
onPingResponseCallback(result) {
const { requestSent, requestReceived, responseSent, responseReceived } = result;
const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived);
this.updateTimeOffset(measurement);
// Avoid overloading network.
if (this.pings >= GreedyPingCount) {
this.pollingInterval = PollingIntervalLowProfile;
} else {
this.pings++;
}
Events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]);
}
/**
* Handles a failed ping request.
* @param {Object} error The error.
*/
onPingRequestErrorCallback(error) {
console.error(error);
Events.trigger(this, 'update', [error, null, null]);
}
/**
* Drops accumulated measurements.
*/
resetMeasurements() {
this.measurement = null;
this.measurements = [];
}
/**
* Starts the time poller.
*/
startPing() {
this.pingStop = false;
this.internalRequestPing();
}
/**
* Stops the time poller.
*/
stopPing() {
this.pingStop = true;
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 remote time to local time.
* @param {Date} remote The time to convert.
* @returns {Date} Local time.
*/
remoteDateToLocal(remote) {
// remote - local = offset
return new Date(remote.getTime() - this.getTimeOffset());
}
/**
* Converts local time to remote time.
* @param {Date} local The time to convert.
* @returns {Date} Remote time.
*/
localDateToRemote(local) {
// remote - local = offset
return new Date(local.getTime() + this.getTimeOffset());
}
}
export default TimeSync;

View file

@ -1,102 +0,0 @@
/**
* Module that manages time syncing with several devices.
* @module components/syncPlay/core/timeSync/TimeSyncCore
*/
import { Events } from 'jellyfin-apiclient';
import appSettings from '../../../../scripts/settings/appSettings';
import { toFloat } from '../../../../utils/string.ts';
import { getSetting } from '../Settings';
import TimeSyncServer from './TimeSyncServer';
/**
* Utility function to offset a given date by a given amount of milliseconds.
* @param {Date} date The date.
* @param {number} offset The offset, in milliseconds.
* @returns {Date} The offset date.
*/
function offsetDate(date, offset) {
return new Date(date.getTime() + offset);
}
/**
* Class that manages time syncing with several devices.
*/
class TimeSyncCore {
constructor() {
this.manager = null;
this.timeSyncServer = null;
this.timeSyncDeviceId = getSetting('timeSyncDevice') || 'server';
this.extraTimeOffset = toFloat(getSetting('extraTimeOffset'), 0.0);
}
/**
* Initializes the core.
* @param {SyncPlayManager} syncPlayManager The SyncPlay manager.
*/
init(syncPlayManager) {
this.manager = syncPlayManager;
this.timeSyncServer = new TimeSyncServer(syncPlayManager);
Events.on(this.timeSyncServer, 'update', (event, error, timeOffset, ping) => {
if (error) {
console.debug('SyncPlay TimeSyncCore: time sync with server issue:', error);
return;
}
Events.trigger(this, 'time-sync-server-update', [timeOffset, ping]);
});
Events.on(appSettings, 'change', (e, name) => {
if (name === 'extraTimeOffset') {
this.extraTimeOffset = toFloat(getSetting('extraTimeOffset'), 0.0);
}
});
}
/**
* Forces time update with server.
*/
forceUpdate() {
this.timeSyncServer.forceUpdate();
}
/**
* Gets the display name of the selected device for time sync.
* @returns {string} The display name.
*/
getActiveDeviceName() {
return 'Server';
}
/**
* Converts server time to local time.
* @param {Date} remote The time to convert.
* @returns {Date} Local time.
*/
remoteDateToLocal(remote) {
const date = this.timeSyncServer.remoteDateToLocal(remote);
return offsetDate(date, -this.extraTimeOffset);
}
/**
* Converts local time to server time.
* @param {Date} local The time to convert.
* @returns {Date} Server time.
*/
localDateToRemote(local) {
const date = this.timeSyncServer.localDateToRemote(local);
return offsetDate(date, this.extraTimeOffset);
}
/**
* Gets time offset that should be used for time syncing, in milliseconds. Takes into account server and active device selected for syncing.
* @returns {number} The time offset.
*/
getTimeOffset() {
return this.timeSyncServer.getTimeOffset() + this.extraTimeOffset;
}
}
export default TimeSyncCore;

View file

@ -1,39 +0,0 @@
/**
* Module that manages time syncing with server.
* @module components/syncPlay/core/timeSync/TimeSyncServer
*/
import TimeSync from './TimeSync';
/**
* Class that manages time syncing with server.
*/
class TimeSyncServer extends TimeSync {
constructor(syncPlayManager) {
super(syncPlayManager);
}
/**
* Makes a ping request to the server.
*/
requestPing() {
const apiClient = this.manager.getApiClient();
const requestSent = new Date();
let responseReceived;
return apiClient.getServerTime().then((response) => {
responseReceived = new Date();
return response.json();
}).then((data) => {
const requestReceived = new Date(data.RequestReceptionTime);
const responseSent = new Date(data.ResponseTransmissionTime);
return Promise.resolve({
requestSent: requestSent,
requestReceived: requestReceived,
responseSent: responseSent,
responseReceived: responseReceived
});
});
}
}
export default TimeSyncServer;

View file

@ -1,212 +0,0 @@
import { Events } from 'jellyfin-apiclient';
import SyncPlay from '../core';
import SyncPlaySettingsEditor from './settings/SettingsEditor';
import loading from '../../loading/loading';
import toast from '../../toast/toast';
import actionsheet from '../../actionSheet/actionSheet';
import globalize from '../../../scripts/globalize';
import playbackPermissionManager from './playbackPermissionManager';
import ServerConnections from '../../ServerConnections';
import './groupSelectionMenu.scss';
/**
* Class that manages the SyncPlay group selection menu.
*/
class GroupSelectionMenu {
constructor() {
// Register to SyncPlay events.
this.syncPlayEnabled = false;
Events.on(SyncPlay.Manager, 'enabled', (e, enabled) => {
this.syncPlayEnabled = enabled;
});
}
/**
* Used when user needs to join a group.
* @param {HTMLElement} button - Element where to place the menu.
* @param {Object} user - Current user.
* @param {Object} apiClient - ApiClient.
*/
showNewJoinGroupSelection(button, user, apiClient) {
const policy = user.localUser ? user.localUser.Policy : {};
apiClient.getSyncPlayGroups().then(function (response) {
response.json().then(function (groups) {
const menuItems = groups.map(function (group) {
return {
name: group.GroupName,
icon: 'person',
id: group.GroupId,
selected: false,
secondaryText: group.Participants.join(', ')
};
});
if (policy.SyncPlayAccess === 'CreateAndJoinGroups') {
menuItems.push({
name: globalize.translate('LabelSyncPlayNewGroup'),
icon: 'add',
id: 'new-group',
selected: true,
secondaryText: globalize.translate('LabelSyncPlayNewGroupDescription')
});
}
if (menuItems.length === 0 && policy.SyncPlayAccess === 'JoinGroups') {
toast({
text: globalize.translate('MessageSyncPlayCreateGroupDenied')
});
loading.hide();
return;
}
const menuOptions = {
title: globalize.translate('HeaderSyncPlaySelectGroup'),
items: menuItems,
positionTo: button,
border: true,
dialogClass: 'syncPlayGroupMenu'
};
actionsheet.show(menuOptions).then(function (id) {
if (id == 'new-group') {
apiClient.createSyncPlayGroup({
GroupName: globalize.translate('SyncPlayGroupDefaultTitle', user.localUser.Name)
});
} else if (id) {
apiClient.joinSyncPlayGroup({
GroupId: id
});
}
}).catch((error) => {
if (error) {
console.error('SyncPlay: unexpected error listing groups:', error);
}
});
loading.hide();
});
}).catch(function (error) {
console.error(error);
loading.hide();
toast({
text: globalize.translate('MessageSyncPlayErrorAccessingGroups')
});
});
}
/**
* Used when user has joined a group.
* @param {HTMLElement} button - Element where to place the menu.
* @param {Object} user - Current user.
* @param {Object} apiClient - ApiClient.
*/
showLeaveGroupSelection(button, user, apiClient) {
const groupInfo = SyncPlay.Manager.getGroupInfo();
const menuItems = [];
if (!SyncPlay.Manager.isPlaylistEmpty() && !SyncPlay.Manager.isPlaybackActive()) {
menuItems.push({
name: globalize.translate('LabelSyncPlayResumePlayback'),
icon: 'play_circle_filled',
id: 'resume-playback',
selected: false,
secondaryText: globalize.translate('LabelSyncPlayResumePlaybackDescription')
});
} else if (SyncPlay.Manager.isPlaybackActive()) {
menuItems.push({
name: globalize.translate('LabelSyncPlayHaltPlayback'),
icon: 'pause_circle_filled',
id: 'halt-playback',
selected: false,
secondaryText: globalize.translate('LabelSyncPlayHaltPlaybackDescription')
});
}
menuItems.push({
name: globalize.translate('Settings'),
icon: 'video_settings',
id: 'settings',
selected: false,
secondaryText: globalize.translate('LabelSyncPlaySettingsDescription')
});
menuItems.push({
name: globalize.translate('LabelSyncPlayLeaveGroup'),
icon: 'meeting_room',
id: 'leave-group',
selected: true,
secondaryText: globalize.translate('LabelSyncPlayLeaveGroupDescription')
});
const menuOptions = {
title: groupInfo.GroupName,
text: groupInfo.Participants.join(', '),
dialogClass: 'syncPlayGroupMenu',
items: menuItems,
positionTo: button,
border: true
};
actionsheet.show(menuOptions).then(function (id) {
if (id == 'resume-playback') {
SyncPlay.Manager.resumeGroupPlayback(apiClient);
} else if (id == 'halt-playback') {
SyncPlay.Manager.haltGroupPlayback(apiClient);
} else if (id == 'leave-group') {
apiClient.leaveSyncPlayGroup();
} else if (id == 'settings') {
new SyncPlaySettingsEditor(apiClient, SyncPlay.Manager.getTimeSyncCore(), { groupInfo: groupInfo })
.embed()
.catch(error => {
if (error) {
console.error('Error creating SyncPlay settings editor', error);
}
});
}
}).catch((error) => {
if (error) {
console.error('SyncPlay: unexpected error showing group menu:', error);
}
});
loading.hide();
}
/**
* Shows a menu to handle SyncPlay groups.
* @param {HTMLElement} button - Element where to place the menu.
*/
show(button) {
loading.show();
// TODO: should feature be disabled if playback permission is missing?
playbackPermissionManager.check().then(() => {
console.debug('Playback is allowed.');
}).catch((error) => {
console.error('Playback not allowed!', error);
toast({
text: globalize.translate('MessageSyncPlayPlaybackPermissionRequired')
});
});
const apiClient = ServerConnections.currentApiClient();
ServerConnections.user(apiClient).then((user) => {
if (this.syncPlayEnabled) {
this.showLeaveGroupSelection(button, user, apiClient);
} else {
this.showNewJoinGroupSelection(button, user, apiClient);
}
}).catch((error) => {
console.error(error);
loading.hide();
toast({
text: globalize.translate('MessageSyncPlayNoGroupsAvailable')
});
});
}
}
/** GroupSelectionMenu singleton. */
const groupSelectionMenu = new GroupSelectionMenu();
export default groupSelectionMenu;

View file

@ -1,4 +0,0 @@
.syncPlayGroupMenu .actionSheetText {
margin-left: 0.6em; /* to line up with the title */
margin-top: 0;
}

View file

@ -1,52 +0,0 @@
import { appHost } from '../../apphost';
/**
* 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 () {
if (appHost.supports('htmlaudioautoplay')) {
return Promise.resolve(true);
}
const media = createTestMediaElement();
return media.play()
.finally(() => {
destroyTestMediaElement(media);
});
}
}
/** PlaybackPermissionManager singleton. */
export default new PlaybackPermissionManager();

View file

@ -1,19 +0,0 @@
/**
* Module that manages the HtmlAudioPlayer for SyncPlay.
* @module components/syncPlay/ui/players/HtmlAudioPlayer
*/
import HtmlVideoPlayer from './HtmlVideoPlayer';
/**
* Class that manages the HtmlAudioPlayer for SyncPlay.
*/
class HtmlAudioPlayer extends HtmlVideoPlayer {
static type = 'htmlaudioplayer';
constructor(player, syncPlayManager) {
super(player, syncPlayManager);
}
}
export default HtmlAudioPlayer;

View file

@ -1,165 +0,0 @@
/**
* Module that manages the HtmlVideoPlayer for SyncPlay.
* @module components/syncPlay/ui/players/HtmlVideoPlayer
*/
import { Events } from 'jellyfin-apiclient';
import NoActivePlayer from './NoActivePlayer';
/**
* Class that manages the HtmlVideoPlayer for SyncPlay.
*/
class HtmlVideoPlayer extends NoActivePlayer {
static type = 'htmlvideoplayer';
constructor(player, syncPlayManager) {
super(player, syncPlayManager);
this.isPlayerActive = false;
this.savedPlaybackRate = 1.0;
this.minBufferingThresholdMillis = 3000;
if (player.currentTimeAsync) {
/**
* Gets current playback position.
* @returns {Promise<number>} The player position, in milliseconds.
*/
this.currentTimeAsync = () => {
return this.player.currentTimeAsync();
};
}
}
/**
* Binds to the player's events. Overrides parent method.
* @param {Object} player The player.
*/
localBindToPlayer() {
super.localBindToPlayer();
const self = this;
this._onPlaybackStart = (player, state) => {
self.isPlayerActive = true;
self.onPlaybackStart(player, state);
};
this._onPlaybackStop = (stopInfo) => {
self.isPlayerActive = false;
self.onPlaybackStop(stopInfo);
};
this._onUnpause = () => {
self.onUnpause();
};
this._onPause = () => {
self.onPause();
};
this._onTimeUpdate = (e) => {
const currentTime = new Date();
const currentPosition = self.player.currentTime();
self.onTimeUpdate(e, {
currentTime: currentTime,
currentPosition: currentPosition
});
};
this._onPlaying = () => {
clearTimeout(self.notifyBuffering);
self.onReady();
};
this._onWaiting = () => {
clearTimeout(self.notifyBuffering);
self.notifyBuffering = setTimeout(() => {
self.onBuffering();
}, self.minBufferingThresholdMillis);
};
Events.on(this.player, 'playbackstart', this._onPlaybackStart);
Events.on(this.player, 'playbackstop', this._onPlaybackStop);
Events.on(this.player, 'unpause', this._onUnpause);
Events.on(this.player, 'pause', this._onPause);
Events.on(this.player, 'timeupdate', this._onTimeUpdate);
Events.on(this.player, 'playing', this._onPlaying);
Events.on(this.player, 'waiting', this._onWaiting);
this.savedPlaybackRate = this.player.getPlaybackRate();
}
/**
* Removes the bindings from the player's events. Overrides parent method.
*/
localUnbindFromPlayer() {
super.localUnbindFromPlayer();
Events.off(this.player, 'playbackstart', this._onPlaybackStart);
Events.off(this.player, 'playbackstop', this._onPlaybackStop);
Events.off(this.player, 'unpause', this._onPlayerUnpause);
Events.off(this.player, 'pause', this._onPlayerPause);
Events.off(this.player, 'timeupdate', this._onTimeUpdate);
Events.off(this.player, 'playing', this._onPlaying);
Events.off(this.player, 'waiting', this._onWaiting);
this.player.setPlaybackRate(this.savedPlaybackRate);
}
/**
* Called when changes are made to the play queue.
*/
onQueueUpdate() {
// TODO: find a more generic event? Tests show that this is working for now.
Events.trigger(this.player, 'playlistitemadd');
}
/**
* Gets player status.
* @returns {boolean} Whether the player has some media loaded.
*/
isPlaybackActive() {
return this.isPlayerActive;
}
/**
* Gets playback status.
* @returns {boolean} Whether the playback is unpaused.
*/
isPlaying() {
return !this.player.paused();
}
/**
* Gets playback position.
* @returns {number} The player position, in milliseconds.
*/
currentTime() {
return this.player.currentTime();
}
/**
* Checks if player has playback rate support.
* @returns {boolean} _true _ if playback rate is supported, false otherwise.
*/
hasPlaybackRate() {
return true;
}
/**
* Sets the playback rate, if supported.
* @param {number} value The playback rate.
*/
setPlaybackRate(value) {
this.player.setPlaybackRate(value);
}
/**
* Gets the playback rate.
* @returns {number} The playback rate.
*/
getPlaybackRate() {
return this.player.getPlaybackRate();
}
}
export default HtmlVideoPlayer;

View file

@ -1,455 +0,0 @@
/**
* Module that manages the PlaybackManager when there's no active player.
* @module components/syncPlay/ui/players/NoActivePlayer
*/
import { playbackManager } from '../../../playback/playbackmanager';
import SyncPlay from '../../core';
import QueueManager from './QueueManager';
let syncPlayManager;
/**
* Class that manages the PlaybackManager when there's no active player.
*/
class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
static type = 'default';
constructor(player, _syncPlayManager) {
super(player, _syncPlayManager);
syncPlayManager = _syncPlayManager;
}
/**
* Binds to the player's events.
*/
localBindToPlayer() {
if (playbackManager.syncPlayEnabled) return;
// Save local callbacks.
playbackManager._localPlayPause = playbackManager.playPause;
playbackManager._localUnpause = playbackManager.unpause;
playbackManager._localPause = playbackManager.pause;
playbackManager._localSeek = playbackManager.seek;
playbackManager._localSendCommand = playbackManager.sendCommand;
// Override local callbacks.
playbackManager.playPause = this.playPauseRequest;
playbackManager.unpause = this.unpauseRequest;
playbackManager.pause = this.pauseRequest;
playbackManager.seek = this.seekRequest;
playbackManager.sendCommand = this.sendCommandRequest;
// Save local callbacks.
playbackManager._localPlayQueueManager = playbackManager._playQueueManager;
playbackManager._localPlay = playbackManager.play;
playbackManager._localSetCurrentPlaylistItem = playbackManager.setCurrentPlaylistItem;
playbackManager._localClearQueue = playbackManager.clearQueue;
playbackManager._localRemoveFromPlaylist = playbackManager.removeFromPlaylist;
playbackManager._localMovePlaylistItem = playbackManager.movePlaylistItem;
playbackManager._localQueue = playbackManager.queue;
playbackManager._localQueueNext = playbackManager.queueNext;
playbackManager._localNextTrack = playbackManager.nextTrack;
playbackManager._localPreviousTrack = playbackManager.previousTrack;
playbackManager._localSetRepeatMode = playbackManager.setRepeatMode;
playbackManager._localSetQueueShuffleMode = playbackManager.setQueueShuffleMode;
playbackManager._localToggleQueueShuffleMode = playbackManager.toggleQueueShuffleMode;
// Override local callbacks.
playbackManager._playQueueManager = new QueueManager(this.manager);
playbackManager.play = this.playRequest;
playbackManager.setCurrentPlaylistItem = this.setCurrentPlaylistItemRequest;
playbackManager.clearQueue = this.clearQueueRequest;
playbackManager.removeFromPlaylist = this.removeFromPlaylistRequest;
playbackManager.movePlaylistItem = this.movePlaylistItemRequest;
playbackManager.queue = this.queueRequest;
playbackManager.queueNext = this.queueNextRequest;
playbackManager.nextTrack = this.nextTrackRequest;
playbackManager.previousTrack = this.previousTrackRequest;
playbackManager.setRepeatMode = this.setRepeatModeRequest;
playbackManager.setQueueShuffleMode = this.setQueueShuffleModeRequest;
playbackManager.toggleQueueShuffleMode = this.toggleQueueShuffleModeRequest;
playbackManager.syncPlayEnabled = true;
}
/**
* Removes the bindings from the player's events.
*/
localUnbindFromPlayer() {
if (!playbackManager.syncPlayEnabled) return;
playbackManager.playPause = playbackManager._localPlayPause;
playbackManager.unpause = playbackManager._localUnpause;
playbackManager.pause = playbackManager._localPause;
playbackManager.seek = playbackManager._localSeek;
playbackManager.sendCommand = playbackManager._localSendCommand;
playbackManager._playQueueManager = playbackManager._localPlayQueueManager; // TODO: should move elsewhere?
playbackManager.play = playbackManager._localPlay;
playbackManager.setCurrentPlaylistItem = playbackManager._localSetCurrentPlaylistItem;
playbackManager.clearQueue = this._localClearQueue;
playbackManager.removeFromPlaylist = playbackManager._localRemoveFromPlaylist;
playbackManager.movePlaylistItem = playbackManager._localMovePlaylistItem;
playbackManager.queue = playbackManager._localQueue;
playbackManager.queueNext = playbackManager._localQueueNext;
playbackManager.nextTrack = playbackManager._localNextTrack;
playbackManager.previousTrack = playbackManager._localPreviousTrack;
playbackManager.setRepeatMode = playbackManager._localSetRepeatMode;
playbackManager.setQueueShuffleMode = playbackManager._localSetQueueShuffleMode;
playbackManager.toggleQueueShuffleMode = playbackManager._localToggleQueueShuffleMode;
playbackManager.syncPlayEnabled = false;
}
/**
* Overrides PlaybackManager's playPause method.
*/
playPauseRequest() {
const controller = syncPlayManager.getController();
controller.playPause();
}
/**
* Overrides PlaybackManager's unpause method.
*/
unpauseRequest() {
const controller = syncPlayManager.getController();
controller.unpause();
}
/**
* Overrides PlaybackManager's pause method.
*/
pauseRequest() {
const controller = syncPlayManager.getController();
controller.pause();
}
/**
* Overrides PlaybackManager's seek method.
*/
seekRequest(positionTicks) {
const controller = syncPlayManager.getController();
controller.seek(positionTicks);
}
/**
* Overrides PlaybackManager's sendCommand method.
*/
sendCommandRequest(command, player) {
console.debug('SyncPlay sendCommand:', command.Name, command);
const controller = syncPlayManager.getController();
const playerWrapper = syncPlayManager.getPlayerWrapper();
const defaultAction = (_command) => {
playerWrapper.localSendCommand(_command);
};
const ignoreCallback = () => {
// Do nothing.
};
const SetRepeatModeCallback = (_command) => {
controller.setRepeatMode(_command.Arguments.RepeatMode);
};
const SetShuffleQueueCallback = (_command) => {
controller.setShuffleMode(_command.Arguments.ShuffleMode);
};
// Commands to override.
const overrideCommands = {
PlaybackRate: ignoreCallback,
SetRepeatMode: SetRepeatModeCallback,
SetShuffleQueue: SetShuffleQueueCallback
};
// Handle command.
const commandHandler = overrideCommands[command.Name];
if (typeof commandHandler === 'function') {
commandHandler(command, player);
} else {
defaultAction(command);
}
}
/**
* Calls original PlaybackManager's unpause method.
*/
localUnpause() {
if (playbackManager.syncPlayEnabled) {
playbackManager._localUnpause(this.player);
} else {
playbackManager.unpause(this.player);
}
}
/**
* Calls original PlaybackManager's pause method.
*/
localPause() {
if (playbackManager.syncPlayEnabled) {
playbackManager._localPause(this.player);
} else {
playbackManager.pause(this.player);
}
}
/**
* Calls original PlaybackManager's seek method.
*/
localSeek(positionTicks) {
if (playbackManager.syncPlayEnabled) {
playbackManager._localSeek(positionTicks, this.player);
} else {
playbackManager.seek(positionTicks, this.player);
}
}
/**
* Calls original PlaybackManager's stop method.
*/
localStop() {
playbackManager.stop(this.player);
}
/**
* Calls original PlaybackManager's sendCommand method.
*/
localSendCommand(cmd) {
if (playbackManager.syncPlayEnabled) {
playbackManager._localSendCommand(cmd, this.player);
} else {
playbackManager.sendCommand(cmd, this.player);
}
}
/**
* Overrides PlaybackManager's play method.
*/
playRequest(options) {
const controller = syncPlayManager.getController();
return controller.play(options);
}
/**
* Overrides PlaybackManager's setCurrentPlaylistItem method.
*/
setCurrentPlaylistItemRequest(playlistItemId) {
const controller = syncPlayManager.getController();
controller.setCurrentPlaylistItem(playlistItemId);
}
/**
* Overrides PlaybackManager's clearQueue method.
*/
clearQueueRequest(clearPlayingItem) {
const controller = syncPlayManager.getController();
controller.clearPlaylist(clearPlayingItem);
}
/**
* Overrides PlaybackManager's removeFromPlaylist method.
*/
removeFromPlaylistRequest(playlistItemIds) {
const controller = syncPlayManager.getController();
controller.removeFromPlaylist(playlistItemIds);
}
/**
* Overrides PlaybackManager's movePlaylistItem method.
*/
movePlaylistItemRequest(playlistItemId, newIndex) {
const controller = syncPlayManager.getController();
controller.movePlaylistItem(playlistItemId, newIndex);
}
/**
* Overrides PlaybackManager's queue method.
*/
queueRequest(options) {
const controller = syncPlayManager.getController();
controller.queue(options);
}
/**
* Overrides PlaybackManager's queueNext method.
*/
queueNextRequest(options) {
const controller = syncPlayManager.getController();
controller.queueNext(options);
}
/**
* Overrides PlaybackManager's nextTrack method.
*/
nextTrackRequest() {
const controller = syncPlayManager.getController();
controller.nextItem();
}
/**
* Overrides PlaybackManager's previousTrack method.
*/
previousTrackRequest() {
const controller = syncPlayManager.getController();
controller.previousItem();
}
/**
* Overrides PlaybackManager's setRepeatMode method.
*/
setRepeatModeRequest(mode) {
const controller = syncPlayManager.getController();
controller.setRepeatMode(mode);
}
/**
* Overrides PlaybackManager's setQueueShuffleMode method.
*/
setQueueShuffleModeRequest(mode) {
const controller = syncPlayManager.getController();
controller.setShuffleMode(mode);
}
/**
* Overrides PlaybackManager's toggleQueueShuffleMode method.
*/
toggleQueueShuffleModeRequest() {
const controller = syncPlayManager.getController();
controller.toggleShuffleMode();
}
/**
* Calls original PlaybackManager's play method.
*/
localPlay(options) {
if (playbackManager.syncPlayEnabled) {
return playbackManager._localPlay(options);
} else {
return playbackManager.play(options);
}
}
/**
* Calls original PlaybackManager's setCurrentPlaylistItem method.
*/
localSetCurrentPlaylistItem(playlistItemId) {
if (playbackManager.syncPlayEnabled) {
return playbackManager._localSetCurrentPlaylistItem(playlistItemId, this.player);
} else {
return playbackManager.setCurrentPlaylistItem(playlistItemId, this.player);
}
}
/**
* Calls original PlaybackManager's removeFromPlaylist method.
*/
localRemoveFromPlaylist(playlistItemIds) {
if (playbackManager.syncPlayEnabled) {
return playbackManager._localRemoveFromPlaylist(playlistItemIds, this.player);
} else {
return playbackManager.removeFromPlaylist(playlistItemIds, this.player);
}
}
/**
* Calls original PlaybackManager's movePlaylistItem method.
*/
localMovePlaylistItem(playlistItemId, newIndex) {
if (playbackManager.syncPlayEnabled) {
return playbackManager._localMovePlaylistItem(playlistItemId, newIndex, this.player);
} else {
return playbackManager.movePlaylistItem(playlistItemId, newIndex, this.player);
}
}
/**
* Calls original PlaybackManager's queue method.
*/
localQueue(options) {
if (playbackManager.syncPlayEnabled) {
return playbackManager._localQueue(options, this.player);
} else {
return playbackManager.queue(options, this.player);
}
}
/**
* Calls original PlaybackManager's queueNext method.
*/
localQueueNext(options) {
if (playbackManager.syncPlayEnabled) {
return playbackManager._localQueueNext(options, this.player);
} else {
return playbackManager.queueNext(options, this.player);
}
}
/**
* Calls original PlaybackManager's nextTrack method.
*/
localNextItem() {
if (playbackManager.syncPlayEnabled) {
playbackManager._localNextTrack(this.player);
} else {
playbackManager.nextTrack(this.player);
}
}
/**
* Calls original PlaybackManager's previousTrack method.
*/
localPreviousItem() {
if (playbackManager.syncPlayEnabled) {
playbackManager._localPreviousTrack(this.player);
} else {
playbackManager.previousTrack(this.player);
}
}
/**
* Calls original PlaybackManager's setRepeatMode method.
*/
localSetRepeatMode(value) {
if (playbackManager.syncPlayEnabled) {
playbackManager._localSetRepeatMode(value, this.player);
} else {
playbackManager.setRepeatMode(value, this.player);
}
}
/**
* Calls original PlaybackManager's setQueueShuffleMode method.
*/
localSetQueueShuffleMode(value) {
if (playbackManager.syncPlayEnabled) {
playbackManager._localSetQueueShuffleMode(value, this.player);
} else {
playbackManager.setQueueShuffleMode(value, this.player);
}
}
/**
* Calls original PlaybackManager's toggleQueueShuffleMode method.
*/
localToggleQueueShuffleMode() {
if (playbackManager.syncPlayEnabled) {
playbackManager._localToggleQueueShuffleMode(this.player);
} else {
playbackManager.toggleQueueShuffleMode(this.player);
}
}
}
export default NoActivePlayer;

View file

@ -1,202 +0,0 @@
/**
* Module that replaces the PlaybackManager's queue.
* @module components/syncPlay/ui/players/QueueManager
*/
/**
* Class that replaces the PlaybackManager's queue.
*/
class QueueManager {
constructor(syncPlayManager) {
this.queueCore = syncPlayManager.getQueueCore();
}
/**
* Placeholder for original PlayQueueManager method.
*/
getPlaylist() {
return this.queueCore.getPlaylist();
}
/**
* Placeholder for original PlayQueueManager method.
*/
setPlaylist() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
queue() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
shufflePlaylist() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
sortShuffledPlaylist() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
clearPlaylist() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
queueNext() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
getCurrentPlaylistIndex() {
return this.queueCore.getCurrentPlaylistIndex();
}
/**
* Placeholder for original PlayQueueManager method.
*/
getCurrentItem() {
const index = this.getCurrentPlaylistIndex();
if (index >= 0) {
const playlist = this.getPlaylist();
return playlist[index];
} else {
return null;
}
}
/**
* Placeholder for original PlayQueueManager method.
*/
getCurrentPlaylistItemId() {
return this.queueCore.getCurrentPlaylistItemId();
}
/**
* Placeholder for original PlayQueueManager method.
*/
setPlaylistState() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
setPlaylistIndex() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
removeFromPlaylist() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
movePlaylistItem() {
// Do nothing.
return {
result: 'noop'
};
}
/**
* Placeholder for original PlayQueueManager method.
*/
reset() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
setRepeatMode() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
getRepeatMode() {
return this.queueCore.getRepeatMode();
}
/**
* Placeholder for original PlayQueueManager method.
*/
setShuffleMode() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
toggleShuffleMode() {
// Do nothing.
}
/**
* Placeholder for original PlayQueueManager method.
*/
getShuffleMode() {
return this.queueCore.getShuffleMode();
}
/**
* Placeholder for original PlayQueueManager method.
*/
getNextItemInfo() {
const playlist = this.getPlaylist();
let newIndex;
switch (this.getRepeatMode()) {
case 'RepeatOne':
newIndex = this.getCurrentPlaylistIndex();
break;
case 'RepeatAll':
newIndex = this.getCurrentPlaylistIndex() + 1;
if (newIndex >= playlist.length) {
newIndex = 0;
}
break;
default:
newIndex = this.getCurrentPlaylistIndex() + 1;
break;
}
if (newIndex < 0 || newIndex >= playlist.length) {
return null;
}
const item = playlist[newIndex];
if (!item) {
return null;
}
return {
item: item,
index: newIndex
};
}
}
export default QueueManager;

View file

@ -1,146 +0,0 @@
/**
* Module that displays an editor for changing SyncPlay settings.
* @module components/syncPlay/settings/SettingsEditor
*/
import { Events } from 'jellyfin-apiclient';
import SyncPlay from '../../core';
import { setSetting } from '../../core/Settings';
import dialogHelper from '../../../dialogHelper/dialogHelper';
import layoutManager from '../../../layoutManager';
import loading from '../../../loading/loading';
import toast from '../../../toast/toast';
import globalize from '../../../../scripts/globalize';
import 'material-design-icons-iconfont';
import '../../../../elements/emby-input/emby-input';
import '../../../../elements/emby-select/emby-select';
import '../../../../elements/emby-button/emby-button';
import '../../../../elements/emby-button/paper-icon-button-light';
import '../../../../elements/emby-checkbox/emby-checkbox';
import '../../../listview/listview.scss';
import '../../../formdialog.scss';
function centerFocus(elem, horiz, on) {
import('../../../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
});
}
/**
* Class that displays an editor for changing SyncPlay settings.
*/
class SettingsEditor {
constructor(apiClient, timeSyncCore, options = {}) {
this.apiClient = apiClient;
this.timeSyncCore = timeSyncCore;
this.options = options;
}
async embed() {
const dialogOptions = {
removeOnClose: true,
scrollY: true
};
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'small';
}
this.context = dialogHelper.createDialog(dialogOptions);
this.context.classList.add('formDialog');
const { default: editorTemplate } = await import('./editor.html');
this.context.innerHTML = globalize.translateHtml(editorTemplate, 'core');
// Set callbacks for form submission
this.context.querySelector('form').addEventListener('submit', (event) => {
// Disable default form submission
if (event) {
event.preventDefault();
}
return false;
});
this.context.querySelector('.btnSave').addEventListener('click', () => {
this.onSubmit();
});
this.context.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(this.context);
});
await this.initEditor();
if (layoutManager.tv) {
centerFocus(this.context.querySelector('.formDialogContent'), false, true);
}
return dialogHelper.open(this.context).then(() => {
if (layoutManager.tv) {
centerFocus(this.context.querySelector('.formDialogContent'), false, false);
}
if (this.context.submitted) {
return Promise.resolve();
}
return Promise.reject();
});
}
async initEditor() {
const { context } = this;
context.querySelector('#txtExtraTimeOffset').value = SyncPlay.Manager.timeSyncCore.extraTimeOffset;
context.querySelector('#chkSyncCorrection').checked = SyncPlay.Manager.playbackCore.enableSyncCorrection;
context.querySelector('#txtMinDelaySpeedToSync').value = SyncPlay.Manager.playbackCore.minDelaySpeedToSync;
context.querySelector('#txtMaxDelaySpeedToSync').value = SyncPlay.Manager.playbackCore.maxDelaySpeedToSync;
context.querySelector('#txtSpeedToSyncDuration').value = SyncPlay.Manager.playbackCore.speedToSyncDuration;
context.querySelector('#txtMinDelaySkipToSync').value = SyncPlay.Manager.playbackCore.minDelaySkipToSync;
context.querySelector('#chkSpeedToSync').checked = SyncPlay.Manager.playbackCore.useSpeedToSync;
context.querySelector('#chkSkipToSync').checked = SyncPlay.Manager.playbackCore.useSkipToSync;
}
onSubmit() {
this.save();
dialogHelper.close(this.context);
}
async save() {
loading.show();
await this.saveToAppSettings();
loading.hide();
toast(globalize.translate('SettingsSaved'));
Events.trigger(this, 'saved');
}
async saveToAppSettings() {
const { context } = this;
const extraTimeOffset = context.querySelector('#txtExtraTimeOffset').value;
const syncCorrection = context.querySelector('#chkSyncCorrection').checked;
const minDelaySpeedToSync = context.querySelector('#txtMinDelaySpeedToSync').value;
const maxDelaySpeedToSync = context.querySelector('#txtMaxDelaySpeedToSync').value;
const speedToSyncDuration = context.querySelector('#txtSpeedToSyncDuration').value;
const minDelaySkipToSync = context.querySelector('#txtMinDelaySkipToSync').value;
const useSpeedToSync = context.querySelector('#chkSpeedToSync').checked;
const useSkipToSync = context.querySelector('#chkSkipToSync').checked;
setSetting('extraTimeOffset', extraTimeOffset);
setSetting('enableSyncCorrection', syncCorrection);
setSetting('minDelaySpeedToSync', minDelaySpeedToSync);
setSetting('maxDelaySpeedToSync', maxDelaySpeedToSync);
setSetting('speedToSyncDuration', speedToSyncDuration);
setSetting('minDelaySkipToSync', minDelaySkipToSync);
setSetting('useSpeedToSync', useSpeedToSync);
setSetting('useSkipToSync', useSkipToSync);
Events.trigger(SyncPlay.Manager, 'settings-update');
}
}
export default SettingsEditor;

View file

@ -1,75 +0,0 @@
<div class="formDialogHeader">
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${ButtonBack}">
<span class="material-icons arrow_back" aria-hidden="true"></span>
</button>
<h3 class="formDialogHeaderTitle">${HeaderSyncPlaySettings}</h3>
</div>
<div class="formDialogContent smoothScrollY">
<div class="dialogContentInner dialog-content-centered">
<form style="margin: auto;">
<h2 class="sectionTitle">${HeaderSyncPlayPlaybackSettings}</h2>
<!-- Sync Correction Setting -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSyncCorrection" />
<span>${LabelSyncPlaySettingsSyncCorrection}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSyncCorrectionHelp}</div>
</div>
<!-- SpeedToSync Settings -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSpeedToSync" />
<span>${LabelSyncPlaySettingsSpeedToSync}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMinDelaySpeedToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMinDelaySpeedToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMaxDelaySpeedToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMaxDelaySpeedToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMaxDelaySpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtSpeedToSyncDuration" pattern="[0-9]*"
label="${LabelSyncPlaySettingsSpeedToSyncDuration}" />
<div class="fieldDescription">${LabelSyncPlaySettingsSpeedToSyncDurationHelp}</div>
</div>
<!-- SkipToSync Settings -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSkipToSync" />
<span>${LabelSyncPlaySettingsSkipToSync}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSkipToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMinDelaySkipToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySkipToSyncHelp}</div>
</div>
<!-- Time Settings -->
<h2 class="sectionTitle">${HeaderSyncPlayTimeSyncSettings}</h2>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtExtraTimeOffset" pattern="[0-9]*"
label="${LabelSyncPlaySettingsExtraTimeOffset}" />
<div class="fieldDescription">${LabelSyncPlaySettingsExtraTimeOffsetHelp}</div>
</div>
</form>
<div class="formDialogFooter" id="footer">
<button is="emby-button" type="submit" class="raised button-submit block btnSave formDialogFooterItem">
<span id="saveButtonText">${Save}</span>
</button>
</div>
</div>
</div>

View file

@ -26,7 +26,8 @@ function getEditorHtml() {
html += '<div is="emby-itemscontainer" class="results vertical-wrap">';
html += '</div>';
html += '</div>';
return html += '</div>';
html += '</div>';
return html;
}
function getDeviceHtml(device) {
@ -61,11 +62,12 @@ function getDeviceHtml(device) {
html += '</div>';
html += '</div>';
html += '</div>';
return html += '</button>';
html += '</button>';
return html;
}
function getTunerName(providerId) {
switch (providerId = providerId.toLowerCase()) {
switch (providerId.toLowerCase()) {
case 'm3u':
return 'M3U';

View file

@ -203,7 +203,7 @@ export default function (page, providerId, options) {
}
function getTunerName(providerId) {
switch (providerId = providerId.toLowerCase()) {
switch (providerId.toLowerCase()) {
case 'm3u':
return 'M3U Playlist';
case 'hdhomerun':

View file

@ -106,7 +106,7 @@ export default function (page, providerId, options) {
}
function getTunerName(providerId) {
switch (providerId = providerId.toLowerCase()) {
switch (providerId.toLowerCase()) {
case 'm3u':
return 'M3U Playlist';
case 'hdhomerun':

View file

@ -103,9 +103,9 @@ function getViewEventDetail(view, {state, url, options = {}}, isRestored) {
const searchParams = new URLSearchParams(url.substring(index + 1));
const params = {};
searchParams.forEach((value, key) =>
params[key] = value
);
searchParams.forEach((value, key) => {
params[key] = value;
});
return {
detail: {