Add playlist-sync and group-wait to SyncPlay

This commit is contained in:
Ionut Andrei Oanca 2020-09-25 09:44:30 +02:00
parent 46a0382c0a
commit d8beb9909f
41 changed files with 3880 additions and 1125 deletions

View file

@ -0,0 +1,221 @@
/**
* 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 SyncPlayController {
constructor() {
this.manager = null;
}
/**
* Initializes the controller.
* @param {SyncPlayManager} 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);
apiClient.requestSyncPlayPlay({
PlayingQueue: queue.join(','),
PlayingItemPosition: options.startIndex ? options.startIndex : 0,
StartPositionTicks: options.startPositionTicks ? options.startPositionTicks : 0
});
};
if (options.items) {
Helper.translateItemsForPlayback(apiClient, options.items, options).then(sendPlayRequest);
} else {
Helper.getItemsForPlayback(apiClient, {
Ids: options.ids.join(',')
}).then(function (result) {
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
});
}
/**
* 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.join(','),
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.join(','),
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 track from playlist in SyncPlay group.
*/
nextTrack() {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayNextTrack({
PlaylistItemId: this.manager.getQueueCore().getCurrentPlaylistItemId()
});
}
/**
* Plays previous track from playlist in SyncPlay group.
*/
previousTrack() {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayPreviousTrack({
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 SyncPlayController;

View file

@ -0,0 +1,240 @@
/**
* 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');
}
/**
* Triggers a show-message event.
* @param {Object} syncPlayManager The SyncPlay manager.
* @param {string} message The message name.
* @param {Array} args Extra data needed for the message, optional.
*/
export function showMessage(syncPlayManager, message, args = []) {
Events.trigger(syncPlayManager, 'show-message', [{
message: message,
args: args
}]);
}
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],
TotalRecordCount: 1
};
});
} 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) {
const items = result.Items;
let index = 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) {
promise = getItemsForPlayback(apiClient, mergePlaybackQueries({
ParentId: firstItem.Id,
Filters: 'IsNotFolder',
Recursive: true,
// These are pre-sorted.
SortBy: options.shuffle ? 'Random' : (['BoxSet'].indexOf(firstItem.Type) === -1 ? 'SortName' : null),
MediaTypes: 'Audio,Video'
}, queryOptions));
} else if (firstItem.Type === 'Episode' && items.length === 1) {
promise = new Promise(function (resolve, reject) {
apiClient.getCurrentUser().then(function (user) {
if (!user.Configuration.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
resolve(null);
return;
}
apiClient.getEpisodes(firstItem.SeriesId, {
IsVirtualUnaired: false,
IsMissing: false,
UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters'
}).then(function (episodesResult) {
let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) {
if (foundItem) {
return true;
}
if (e.Id === firstItem.Id) {
foundItem = true;
return true;
}
return false;
});
episodesResult.TotalRecordCount = episodesResult.Items.length;
resolve(episodesResult);
}, reject);
});
});
}
if (promise) {
return promise.then(function (result) {
return result ? result.Items : items;
});
} else {
return Promise.resolve(items);
}
}

View file

@ -0,0 +1,13 @@
import * as Helper from './helper';
import Manager from './manager';
import PlayerFactory from './players/factory';
import GenericPlayer from './players/genericPlayer';
export default {
Helper,
Manager,
PlayerFactory,
Players: {
GenericPlayer
}
};

View file

@ -0,0 +1,479 @@
/**
* Module that manages the SyncPlay feature.
* @module components/syncPlay/core/manager
*/
import { Events } from 'jellyfin-apiclient';
import * as Helper from './helper';
import PlayerFactory from './players/factory';
import TimeSyncCore from './timeSync/core';
import SyncPlayPlaybackCore from './playbackCore';
import SyncPlayQueueCore from './queueCore';
import SyncPlayController from './controller';
/**
* Class that manages the SyncPlay feature.
*/
class SyncPlayManager {
constructor() {
this.apiClient = null;
this.timeSyncCore = new TimeSyncCore();
this.playbackCore = new SyncPlayPlaybackCore();
this.queueCore = new SyncPlayQueueCore();
this.controller = new SyncPlayController();
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) {
if (!apiClient) {
throw new Error('ApiClient is null!');
}
// Set ApiClient.
this.apiClient = apiClient;
// Get default player wrapper.
this.playerWrapper = 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
});
}
});
}
/**
* Gets the time sync core.
* @returns {TimeSyncCore} The time sync core.
*/
getTimeSyncCore() {
return this.timeSyncCore;
}
/**
* Gets the playback core.
* @returns {SyncPlayPlaybackCore} The playback core.
*/
getPlaybackCore() {
return this.playbackCore;
}
/**
* Gets the queue core.
* @returns {SyncPlayQueueCore} The queue core.
*/
getQueueCore() {
return this.queueCore;
}
/**
* Gets the controller used to manage SyncPlay playback.
* @returns {SyncPlayController} 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, newTarget, oldPlayer) {
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 = 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 = 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':
Helper.showMessage(this, 'MessageSyncPlayUserJoined', [cmd.Data]);
break;
case 'UserLeft':
Helper.showMessage(this, 'MessageSyncPlayUserLeft', [cmd.Data]);
break;
case 'GroupJoined':
cmd.Data.LastUpdatedAt = new Date(cmd.Data.LastUpdatedAt);
this.enableSyncPlay(apiClient, cmd.Data, true);
break;
case 'SyncPlayIsDisabled':
Helper.showMessage(this, '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':
Helper.showMessage(this, 'MessageSyncPlayGroupDoesNotExist');
break;
case 'CreateGroupDenied':
Helper.showMessage(this, 'MessageSyncPlayCreateGroupDenied');
break;
case 'JoinGroupDenied':
Helper.showMessage(this, 'MessageSyncPlayJoinGroupDenied');
break;
case 'LibraryAccessDenied':
Helper.showMessage(this, '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.
* @param {Object} apiClient The ApiClient.
*/
processCommand(cmd, apiClient) {
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.
* @param {Object} apiClient The ApiClient.
*/
processStateChange(update, apiClient) {
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) {
Helper.showMessage(this, '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) {
Helper.showMessage(this, '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]);
}
}
/** SyncPlayManager singleton. */
const syncPlayManager = new SyncPlayManager();
export default syncPlayManager;

View file

@ -0,0 +1,577 @@
/**
* Module that manages the playback of SyncPlay.
* @module components/syncPlay/core/playbackCore
*/
import { Events } from 'jellyfin-apiclient';
import * as Helper from './helper';
/**
* Class that manages the playback of SyncPlay.
*/
class SyncPlayPlaybackCore {
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.enableSyncCorrection = true; // User setting to disable sync during playback.
this.playerIsBuffering = false;
this.lastCommand = null; // Last scheduled playback command, might not be the latest one.
this.scheduledCommandTimeout = null;
this.syncTimeout = null;
}
/**
* Initializes the core.
* @param {SyncPlayManager} syncPlayManager The SyncPlay manager.
*/
init(syncPlayManager) {
this.manager = syncPlayManager;
this.timeSyncCore = syncPlayManager.getTimeSyncCore();
// Minimum required delay for SpeedToSync to kick in, in milliseconds.
this.minDelaySpeedToSync = 60.0;
// Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds.
this.maxDelaySpeedToSync = 3000.0;
// Time during which the playback is sped up, in milliseconds.
this.speedToSyncDuration = 1000.0;
// Minimum required delay for SkipToSync to kick in, in milliseconds.
this.minDelaySkipToSync = 400.0;
// Whether SpeedToSync should be used.
this.useSpeedToSync = true;
// Whether SkipToSync should be used.
this.useSkipToSync = true;
// Whether sync correction during playback is active.
this.enableSyncCorrection = true;
}
/**
* 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]);
this.manager.releaseCurrentPlayer();
}
/**
* 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.
*/
sendBufferingRequest(isBuffering = true) {
const playerWrapper = this.manager.getPlayerWrapper();
const currentPosition = 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 apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayBuffering({
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId,
BufferingDone: !isBuffering
});
}
/**
* 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.
*/
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.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.
*/
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.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;
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;
this.playbackDiffMillis = diffMillis;
// 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 SyncPlayPlaybackCore;

View file

@ -0,0 +1,73 @@
/**
* Module that creates wrappers for known players.
* @module components/syncPlay/core/players/factory
*/
import SyncPlayGenericPlayer from './genericPlayer';
/**
* Class that creates wrappers for known players.
*/
class SyncPlayPlayerFactory {
constructor() {
this.wrappers = {};
this.DefaultWrapper = SyncPlayGenericPlayer;
}
/**
* Registers a wrapper to the list of players that can be managed.
* @param {SyncPlayGenericPlayer} 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 {SyncPlayGenericPlayer} 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);
}
console.debug('SyncPlay WrapperFactory getWrapper:', player.id);
const Wrapper = this.wrappers[player.id];
if (Wrapper) {
return new Wrapper(player, syncPlayManager);
}
console.debug(`SyncPlay WrapperFactory getWrapper: unknown player ${player.id}, 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;
}
}
}
/** SyncPlayPlayerFactory singleton. */
const playerFactory = new SyncPlayPlayerFactory();
export default playerFactory;

View file

@ -0,0 +1,305 @@
/**
* 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 SyncPlayGenericPlayer {
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.
*/
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() {
}
/**
* Pauses the player.
*/
localPause() {
}
/**
* Seeks the player to the specified position.
* @param {number} positionTicks The new position.
*/
localSeek(positionTicks) {
}
/**
* Stops the player.
*/
localStop() {
}
/**
* Sends a command to the player.
* @param {Object} command The command.
*/
localSendCommand(command) {
}
/**
* Starts playback.
* @param {Object} options Playback data.
*/
localPlay(options) {
}
/**
* Sets playing item from playlist.
* @param {string} playlistItemId The item to play.
*/
localSetCurrentPlaylistItem(playlistItemId) {
}
/**
* Removes items from playlist.
* @param {Array} playlistItemIds The items to remove.
*/
localRemoveFromPlaylist(playlistItemIds) {
}
/**
* Moves an item in the playlist.
* @param {string} playlistItemId The item to move.
* @param {number} newIndex The new position.
*/
localMovePlaylistItem(playlistItemId, newIndex) {
}
/**
* Queues in the playlist.
* @param {Object} options Queue data.
*/
localQueue(options) {
}
/**
* Queues after the playing item in the playlist.
* @param {Object} options Queue data.
*/
localQueueNext(options) {
}
/**
* Picks next item in playlist.
*/
localNextTrack() {
}
/**
* Picks previous item in playlist.
*/
localPreviousTrack() {
}
/**
* Sets repeat mode.
* @param {string} value The repeat mode.
*/
localSetRepeatMode(value) {
}
/**
* Sets shuffle mode.
* @param {string} value The shuffle mode.
*/
localSetQueueShuffleMode(value) {
}
/**
* Toggles shuffle mode.
*/
localToggleQueueShuffleMode() {
}
}
export default SyncPlayGenericPlayer;

View file

@ -0,0 +1,372 @@
/**
* Module that manages the queue of SyncPlay.
* @module components/syncPlay/core/queueCore
*/
import * as Helper from './helper';
/**
* Class that manages the queue of SyncPlay.
*/
class SyncPlayQueueCore {
constructor() {
this.manager = null;
this.lastPlayQueueUpdate = null;
this.playlist = [];
}
/**
* Initializes the core.
* @param {SyncPlayManager} 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 'NextTrack':
case 'PreviousTrack': {
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(() => {
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.currentTime();
const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();
apiClient.requestSyncPlayBuffering({
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: this.getCurrentPlaylistItemId(),
BufferingDone: true
});
}).catch((error) => {
console.error('Error while waiting for `playbackstart` event!', origin, error);
if (!this.manager.isSyncPlayEnabled()) {
Helper.showMessage(this.manager, 'MessageSyncPlayErrorMedia');
}
this.manager.haltGroupPlayback(apiClient);
return;
});
}
/**
* Prepares this client for playback by loading the group's content.
* @param {Object} apiClient The ApiClient.
*/
startPlayback(apiClient) {
if (!this.manager.isFollowingGroupPlayback()) {
console.debug('SyncPlay startPlayback: ignoring, not following playback.');
return Promise.reject();
}
if (this.isPlaylistEmpty()) {
console.debug('SyncPlay startPlayback: empty playlist.');
return;
}
// Estimate start position ticks from last playback command, if available.
const playbackCommand = this.manager.getLastPlaybackCommand();
let startPositionTicks = 0;
if (playbackCommand && playbackCommand.EmittedAt.getTime() >= this.getLastUpdateTime()) {
// Prefer playback commands as they're more frequent (and also because playback position is PlaybackCore's concern).
startPositionTicks = this.manager.getPlaybackCore().estimateCurrentTicks(playbackCommand.PositionTicks, playbackCommand.When);
} else {
// A PlayQueueUpdate is emited only on queue changes so it's less reliable for playback position syncing.
const oldStartPositionTicks = this.getStartPositionTicks();
const lastQueueUpdateDate = this.getLastUpdate();
startPositionTicks = this.manager.getPlaybackCore().estimateCurrentTicks(oldStartPositionTicks, lastQueueUpdateDate);
}
const serverId = apiClient.serverInfo().Id;
const playerWrapper = this.manager.getPlayerWrapper();
playerWrapper.localPlay({
ids: this.getPlaylistAsItemIds(),
startPositionTicks: startPositionTicks,
startIndex: this.getCurrentPlaylistIndex(),
serverId: serverId
}).then(() => {
this.scheduleReadyRequestOnPlaybackStart(apiClient, 'startPlayback');
}).catch((error) => {
console.error(error);
Helper.showMessage(this.manager, '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 SyncPlayQueueCore;

View file

@ -0,0 +1,78 @@
/**
* Module that manages time syncing with several devices.
* @module components/syncPlay/core/timeSync/core
*/
import { Events } from 'jellyfin-apiclient';
import TimeSyncServer from './server';
/**
* Class that manages time syncing with several devices.
*/
class TimeSyncCore {
constructor() {
this.manager = null;
this.timeSyncServer = null;
}
/**
* 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]);
});
}
/**
* 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) {
return this.timeSyncServer.remoteDateToLocal(remote);
}
/**
* Converts local time to server time.
* @param {Date} local The time to convert.
* @returns {Date} Server time.
*/
localDateToRemote(local) {
return this.timeSyncServer.localDateToRemote(local);
}
/**
* Gets time offset that should be used for time syncing, in milliseconds.
* @returns {number} The time offset.
*/
getTimeOffset() {
return this.timeSyncServer.getTimeOffset();
}
}
export default TimeSyncCore;

View file

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

View file

@ -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;