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:
commit
104ad71ea7
128 changed files with 1242 additions and 1454 deletions
|
@ -339,11 +339,7 @@ export class BookPlayer {
|
|||
}
|
||||
|
||||
canPlayItem(item) {
|
||||
if (item.Path && item.Path.endsWith('epub')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return item.Path && item.Path.endsWith('epub');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -452,11 +452,9 @@ function normalizeImages(state) {
|
|||
if (state && state.NowPlayingItem) {
|
||||
const item = state.NowPlayingItem;
|
||||
|
||||
if (!item.ImageTags || !item.ImageTags.Primary) {
|
||||
if (item.PrimaryImageTag) {
|
||||
item.ImageTags = item.ImageTags || {};
|
||||
item.ImageTags.Primary = item.PrimaryImageTag;
|
||||
}
|
||||
if ((!item.ImageTags || !item.ImageTags.Primary) && item.PrimaryImageTag) {
|
||||
item.ImageTags = item.ImageTags || {};
|
||||
item.ImageTags.Primary = item.PrimaryImageTag;
|
||||
}
|
||||
if (item.BackdropImageTag && item.BackdropItemId === item.Id) {
|
||||
item.BackdropImageTags = [item.BackdropImageTag];
|
||||
|
|
|
@ -172,10 +172,8 @@ export class ComicsPlayer {
|
|||
|
||||
onWindowKeyUp(e) {
|
||||
const key = keyboardnavigation.getKeyName(e);
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
this.stop();
|
||||
break;
|
||||
if (key === 'Escape') {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -358,11 +356,7 @@ export class ComicsPlayer {
|
|||
}
|
||||
|
||||
canPlayItem(item) {
|
||||
if (item.Path && (item.Path.endsWith('cbz') || item.Path.endsWith('cbr'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return item.Path && (item.Path.endsWith('cbz') || item.Path.endsWith('cbr'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -41,13 +41,9 @@ function cancelFadeTimeout() {
|
|||
}
|
||||
|
||||
function supportsFade() {
|
||||
if (browser.tv) {
|
||||
// Not working on tizen.
|
||||
// We could possibly enable on other tv's, but all smart tv browsers tend to be pretty primitive
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
// Not working on tizen.
|
||||
// We could possibly enable on other tv's, but all smart tv browsers tend to be pretty primitive
|
||||
return !browser.tv;
|
||||
}
|
||||
|
||||
function requireHlsPlayer(callback) {
|
||||
|
@ -417,10 +413,7 @@ class HtmlAudioPlayer {
|
|||
|
||||
// This is a retry after error
|
||||
resume() {
|
||||
const mediaElement = this._mediaElement;
|
||||
if (mediaElement) {
|
||||
mediaElement.play();
|
||||
}
|
||||
this.unpause();
|
||||
}
|
||||
|
||||
unpause() {
|
||||
|
|
|
@ -67,16 +67,12 @@ function tryRemoveElement(elem) {
|
|||
}
|
||||
|
||||
function enableNativeTrackSupport(currentSrc, track) {
|
||||
if (track) {
|
||||
if (track.DeliveryMethod === 'Embed') {
|
||||
return true;
|
||||
}
|
||||
if (track?.DeliveryMethod === 'Embed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (browser.firefox) {
|
||||
if ((currentSrc || '').toLowerCase().includes('.m3u8')) {
|
||||
return false;
|
||||
}
|
||||
if (browser.firefox && (currentSrc || '').toLowerCase().includes('.m3u8')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (browser.ps4) {
|
||||
|
@ -92,11 +88,9 @@ function tryRemoveElement(elem) {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (browser.iOS) {
|
||||
if (browser.iOS && (browser.iosVersion || 10) < 10) {
|
||||
// works in the browser but not the native app
|
||||
if ((browser.iosVersion || 10) < 10) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (track) {
|
||||
|
@ -279,10 +273,6 @@ function tryRemoveElement(elem) {
|
|||
* @type {any | undefined}
|
||||
*/
|
||||
#lastProfile;
|
||||
/**
|
||||
* @type {MutationObserver | IntersectionObserver | undefined} (Unclear observer typing)
|
||||
*/
|
||||
#resizeObserver;
|
||||
|
||||
constructor() {
|
||||
if (browser.edgeUwp) {
|
||||
|
@ -969,11 +959,6 @@ function tryRemoveElement(elem) {
|
|||
* @private
|
||||
*/
|
||||
destroyCustomTrack(videoElement) {
|
||||
if (this.#resizeObserver) {
|
||||
this.#resizeObserver.disconnect();
|
||||
this.#resizeObserver = null;
|
||||
}
|
||||
|
||||
if (this.#videoSubtitlesElem) {
|
||||
const subtitlesContainer = this.#videoSubtitlesElem.parentNode;
|
||||
if (subtitlesContainer) {
|
||||
|
@ -1497,14 +1482,14 @@ function tryRemoveElement(elem) {
|
|||
if (
|
||||
// Check non-standard Safari PiP support
|
||||
typeof video.webkitSupportsPresentationMode === 'function' && video.webkitSupportsPresentationMode('picture-in-picture') && typeof video.webkitSetPresentationMode === 'function'
|
||||
// Check non-standard Windows PiP support
|
||||
|| (window.Windows
|
||||
&& Windows.UI.ViewManagement.ApplicationView.getForCurrentView()
|
||||
.isViewModeSupported(Windows.UI.ViewManagement.ApplicationViewMode.compactOverlay))
|
||||
// Check standard PiP support
|
||||
|| document.pictureInPictureEnabled
|
||||
) {
|
||||
list.push('PictureInPicture');
|
||||
} else if (window.Windows) {
|
||||
if (Windows.UI.ViewManagement.ApplicationView.getForCurrentView().isViewModeSupported(Windows.UI.ViewManagement.ApplicationViewMode.compactOverlay)) {
|
||||
list.push('PictureInPicture');
|
||||
}
|
||||
}
|
||||
|
||||
if (browser.safari || browser.iOS || browser.iPad) {
|
||||
|
@ -1565,13 +1550,7 @@ function tryRemoveElement(elem) {
|
|||
}
|
||||
|
||||
const video = this.#mediaElement;
|
||||
if (video) {
|
||||
if (video.audioTracks) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return !!video?.audioTracks;
|
||||
}
|
||||
|
||||
static onPictureInPictureError(err) {
|
||||
|
@ -1703,10 +1682,7 @@ function tryRemoveElement(elem) {
|
|||
|
||||
// This is a retry after error
|
||||
resume() {
|
||||
const mediaElement = this.#mediaElement;
|
||||
if (mediaElement) {
|
||||
mediaElement.play();
|
||||
}
|
||||
this.unpause();
|
||||
}
|
||||
|
||||
unpause() {
|
||||
|
|
|
@ -304,11 +304,7 @@ export class PdfPlayer {
|
|||
}
|
||||
|
||||
canPlayItem(item) {
|
||||
if (item.Path && item.Path.endsWith('pdf')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return item.Path && item.Path.endsWith('pdf');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -159,11 +159,9 @@ function normalizeImages(state, apiClient) {
|
|||
if (state && state.NowPlayingItem) {
|
||||
const item = state.NowPlayingItem;
|
||||
|
||||
if (!item.ImageTags || !item.ImageTags.Primary) {
|
||||
if (item.PrimaryImageTag) {
|
||||
item.ImageTags = item.ImageTags || {};
|
||||
item.ImageTags.Primary = item.PrimaryImageTag;
|
||||
}
|
||||
if (!item.ImageTags || !item.ImageTags.Primary && item.PrimaryImageTag) {
|
||||
item.ImageTags = item.ImageTags || {};
|
||||
item.ImageTags.Primary = item.PrimaryImageTag;
|
||||
}
|
||||
if (item.BackdropImageTag && item.BackdropItemId === item.Id) {
|
||||
item.BackdropImageTags = [item.BackdropImageTag];
|
||||
|
|
233
src/plugins/syncPlay/core/Controller.js
Normal file
233
src/plugins/syncPlay/core/Controller.js
Normal file
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* 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;
|
230
src/plugins/syncPlay/core/Helper.js
Normal file
230
src/plugins/syncPlay/core/Helper.js
Normal file
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
498
src/plugins/syncPlay/core/Manager.js
Normal file
498
src/plugins/syncPlay/core/Manager.js
Normal file
|
@ -0,0 +1,498 @@
|
|||
/**
|
||||
* 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 '../../../components/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;
|
608
src/plugins/syncPlay/core/PlaybackCore.js
Normal file
608
src/plugins/syncPlay/core/PlaybackCore.js
Normal file
|
@ -0,0 +1,608 @@
|
|||
/**
|
||||
* 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;
|
374
src/plugins/syncPlay/core/QueueCore.js
Normal file
374
src/plugins/syncPlay/core/QueueCore.js
Normal file
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* Module that manages the queue of SyncPlay.
|
||||
* @module components/syncPlay/core/QueueCore
|
||||
*/
|
||||
|
||||
import globalize from '../../../scripts/globalize';
|
||||
import toast from '../../../components/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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
28
src/plugins/syncPlay/core/Settings.js
Normal file
28
src/plugins/syncPlay/core/Settings.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
16
src/plugins/syncPlay/core/index.js
Normal file
16
src/plugins/syncPlay/core/index.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as Helper from './Helper';
|
||||
import ManagerClass from './Manager';
|
||||
import PlayerFactoryClass from './players/PlayerFactory';
|
||||
import GenericPlayer from './players/GenericPlayer';
|
||||
|
||||
const PlayerFactory = new PlayerFactoryClass();
|
||||
const Manager = new ManagerClass(PlayerFactory);
|
||||
|
||||
export default {
|
||||
Helper,
|
||||
Manager,
|
||||
PlayerFactory,
|
||||
Players: {
|
||||
GenericPlayer
|
||||
}
|
||||
};
|
316
src/plugins/syncPlay/core/players/GenericPlayer.js
Normal file
316
src/plugins/syncPlay/core/players/GenericPlayer.js
Normal file
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* 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;
|
73
src/plugins/syncPlay/core/players/PlayerFactory.js
Normal file
73
src/plugins/syncPlay/core/players/PlayerFactory.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* 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 {typeof 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 {typeof 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;
|
220
src/plugins/syncPlay/core/timeSync/TimeSync.js
Normal file
220
src/plugins/syncPlay/core/timeSync/TimeSync.js
Normal file
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* 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;
|
102
src/plugins/syncPlay/core/timeSync/TimeSyncCore.js
Normal file
102
src/plugins/syncPlay/core/timeSync/TimeSyncCore.js
Normal file
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* 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;
|
35
src/plugins/syncPlay/core/timeSync/TimeSyncServer.js
Normal file
35
src/plugins/syncPlay/core/timeSync/TimeSyncServer.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* 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 {
|
||||
/**
|
||||
* 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;
|
49
src/plugins/syncPlay/plugin.ts
Normal file
49
src/plugins/syncPlay/plugin.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { Events } from 'jellyfin-apiclient';
|
||||
|
||||
import { playbackManager } from '../../components/playback/playbackmanager';
|
||||
import ServerConnections from '../../components/ServerConnections';
|
||||
import SyncPlay from './core';
|
||||
import SyncPlayNoActivePlayer from './ui/players/NoActivePlayer';
|
||||
import SyncPlayHtmlVideoPlayer from './ui/players/HtmlVideoPlayer';
|
||||
import SyncPlayHtmlAudioPlayer from './ui/players/HtmlAudioPlayer';
|
||||
|
||||
class SyncPlayPlugin {
|
||||
name: string;
|
||||
id: string;
|
||||
type: string;
|
||||
priority: number;
|
||||
|
||||
constructor() {
|
||||
this.name = 'SyncPlay Plugin';
|
||||
this.id = 'syncplay';
|
||||
// NOTE: This should probably be a "mediaplayer" so the playback manager can handle playback logic, but
|
||||
// SyncPlay needs refactored so it does not have an independent playback manager.
|
||||
this.type = 'syncplay';
|
||||
this.priority = 1;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Register player wrappers.
|
||||
SyncPlay.PlayerFactory.setDefaultWrapper(SyncPlayNoActivePlayer);
|
||||
SyncPlay.PlayerFactory.registerWrapper(SyncPlayHtmlVideoPlayer);
|
||||
SyncPlay.PlayerFactory.registerWrapper(SyncPlayHtmlAudioPlayer);
|
||||
|
||||
// Listen for player changes.
|
||||
Events.on(playbackManager, 'playerchange', (_, newPlayer) => {
|
||||
SyncPlay.Manager.onPlayerChange(newPlayer);
|
||||
});
|
||||
|
||||
// Start SyncPlay.
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
if (apiClient) SyncPlay.Manager.init(apiClient);
|
||||
|
||||
// FIXME: Multiple apiClients?
|
||||
Events.on(ServerConnections, 'apiclientcreated', (_, newApiClient) => SyncPlay.Manager.init(newApiClient));
|
||||
Events.on(ServerConnections, 'localusersignedin', () => SyncPlay.Manager.updateApiClient(ServerConnections.currentApiClient()));
|
||||
Events.on(ServerConnections, 'localusersignedout', () => SyncPlay.Manager.updateApiClient(ServerConnections.currentApiClient()));
|
||||
}
|
||||
}
|
||||
|
||||
export default SyncPlayPlugin;
|
212
src/plugins/syncPlay/ui/groupSelectionMenu.js
Normal file
212
src/plugins/syncPlay/ui/groupSelectionMenu.js
Normal file
|
@ -0,0 +1,212 @@
|
|||
import { Events } from 'jellyfin-apiclient';
|
||||
import SyncPlay from '../core';
|
||||
import SyncPlaySettingsEditor from './settings/SettingsEditor';
|
||||
import loading from '../../../components/loading/loading';
|
||||
import toast from '../../../components/toast/toast';
|
||||
import actionsheet from '../../../components/actionSheet/actionSheet';
|
||||
import globalize from '../../../scripts/globalize';
|
||||
import playbackPermissionManager from './playbackPermissionManager';
|
||||
import ServerConnections from '../../../components/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;
|
4
src/plugins/syncPlay/ui/groupSelectionMenu.scss
Normal file
4
src/plugins/syncPlay/ui/groupSelectionMenu.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
.syncPlayGroupMenu .actionSheetText {
|
||||
margin-left: 0.6em; /* to line up with the title */
|
||||
margin-top: 0;
|
||||
}
|
52
src/plugins/syncPlay/ui/playbackPermissionManager.js
Normal file
52
src/plugins/syncPlay/ui/playbackPermissionManager.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { appHost } from '../../../components/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();
|
15
src/plugins/syncPlay/ui/players/HtmlAudioPlayer.js
Normal file
15
src/plugins/syncPlay/ui/players/HtmlAudioPlayer.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
export default HtmlAudioPlayer;
|
165
src/plugins/syncPlay/ui/players/HtmlVideoPlayer.js
Normal file
165
src/plugins/syncPlay/ui/players/HtmlVideoPlayer.js
Normal file
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* 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;
|
455
src/plugins/syncPlay/ui/players/NoActivePlayer.js
Normal file
455
src/plugins/syncPlay/ui/players/NoActivePlayer.js
Normal file
|
@ -0,0 +1,455 @@
|
|||
/**
|
||||
* Module that manages the PlaybackManager when there's no active player.
|
||||
* @module components/syncPlay/ui/players/NoActivePlayer
|
||||
*/
|
||||
|
||||
import { playbackManager } from '../../../../components/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;
|
202
src/plugins/syncPlay/ui/players/QueueManager.js
Normal file
202
src/plugins/syncPlay/ui/players/QueueManager.js
Normal file
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* Module that replaces the PlaybackManager's queue.
|
||||
* @module components/syncPlay/ui/players/QueueManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class that replaces the PlaybackManager's queue.
|
||||
*/
|
||||
class QueueManager {
|
||||
constructor(syncPlayManager) {
|
||||
this.queueCore = syncPlayManager.getQueueCore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for original PlayQueueManager method.
|
||||
*/
|
||||
getPlaylist() {
|
||||
return this.queueCore.getPlaylist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for original PlayQueueManager method.
|
||||
*/
|
||||
setPlaylist() {
|
||||
// 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;
|
146
src/plugins/syncPlay/ui/settings/SettingsEditor.js
Normal file
146
src/plugins/syncPlay/ui/settings/SettingsEditor.js
Normal file
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* 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 '../../../../components/dialogHelper/dialogHelper';
|
||||
import layoutManager from '../../../../components/layoutManager';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/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 '../../../../components/listview/listview.scss';
|
||||
import '../../../../components/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;
|
75
src/plugins/syncPlay/ui/settings/editor.html
Normal file
75
src/plugins/syncPlay/ui/settings/editor.html
Normal file
|
@ -0,0 +1,75 @@
|
|||
<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>
|
|
@ -333,10 +333,8 @@ class YoutubePlayer {
|
|||
setVolume(val) {
|
||||
const currentYoutubePlayer = this.currentYoutubePlayer;
|
||||
|
||||
if (currentYoutubePlayer) {
|
||||
if (val != null) {
|
||||
currentYoutubePlayer.setVolume(val);
|
||||
}
|
||||
if (currentYoutubePlayer && val != null) {
|
||||
currentYoutubePlayer.setVolume(val);
|
||||
}
|
||||
}
|
||||
getVolume() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue