1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00
jellyfin-web/src/plugins/syncPlay/core/Manager.js
2023-03-09 00:20:11 -05:00

498 lines
15 KiB
JavaScript

/**
* Module that manages the SyncPlay feature.
* @module components/syncPlay/core/Manager
*/
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';
import Events from '../../../utils/events.ts';
/**
* 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 {ApiClient|undefined} 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, 10) : 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;