jellyfish-web/src/components/syncplay/syncplayManager.js

705 lines
24 KiB
JavaScript
Raw Normal View History

2020-04-03 18:49:19 +02:00
/* eslint-disable indent */
/**
* Module that manages the Syncplay feature.
* @module components/syncplay/syncplayManager
*/
import events from 'events';
import connectionManager from 'connectionManager';
import playbackManager from 'playbackManager';
import toast from 'toast';
import globalize from 'globalize';
/**
* Waits for an event to be triggered on an object.
* @param {Object} emitter Object on which to listen for events.
* @param {string} eventType Event name to listen for.
* @returns {Promise} A promise that resolves when the event is triggered.
*/
function waitForEvent(emitter, eventType) {
return new Promise((resolve) => {
var callback = () => {
events.off(emitter, eventType, callback);
resolve(arguments);
};
events.on(emitter, eventType, callback);
});
}
/**
* Gets active player id.
* @returns {string} The player's id.
*/
function getActivePlayerId() {
var info = playbackManager.getPlayerInfo();
return info ? info.id : null;
}
/**
* Playback synchronization
*/
const MaxAcceptedDelaySpeedToSync = 50; // milliseconds, delay after which SpeedToSync is enabled
const MaxAcceptedDelaySkipToSync = 300; // milliseconds, delay after which SkipToSync is enabled
const SyncMethodThreshold = 2000; // milliseconds, switches between SpeedToSync or SkipToSync
const SpeedUpToSyncTime = 1000; // milliseconds, duration in which the playback is sped up
/**
* Time estimation
*/
const PingIntervalTimeoutGreedy = 1000; // milliseconds
const PingIntervalTimeoutLowProfile = 60000; // milliseconds
const GreedyPingCount = 3;
/**
* Class that manages the Syncplay feature.
*/
class SyncplayManager {
constructor() {
this.playbackRateSupported = false;
this.syncEnabled = false;
this.playbackDiffMillis = 0; // used for stats
this.syncMethod = "None"; // used for stats
this.lastPlaybackWaiting = null; // used to determine if player's buffering
this.minBufferingThresholdMillis = 1000;
this.currentPlayer = null;
this.syncplayEnabledAt = null; // Server time of when Syncplay has been enabled
this.syncplayReady = false; // Syncplay is ready after first ping to server
this.lastCommand = null;
this.queuedCommand = null;
this.scheduledCommand = null;
this.syncTimeout = null;
this.pingStop = true;
this.pingIntervalTimeout = PingIntervalTimeoutGreedy;
this.pingInterval = null;
this.initTimeDiff = 0; // number of pings
this.timeDiff = 0; // local time minus server time
this.roundTripDuration = 0;
this.notifySyncplayReady = false;
events.on(playbackManager, "playerchange", () => {
this.onPlayerChange();
});
this.bindToPlayer(playbackManager.getCurrentPlayer());
}
/**
* Called when the player changes.
*/
onPlayerChange () {
this.bindToPlayer(playbackManager.getCurrentPlayer());
events.trigger(this, "PlayerChange", [this.currentPlayer]);
}
/**
* Called on playback state changes.
* @param {Object} e The playback state change event.
*/
onPlayPauseStateChanged (e) {
events.trigger(this, "PlayPauseStateChange", [this.currentPlayer]);
}
/**
* Called on playback progress.
* @param {Object} e The time update event.
*/
onTimeUpdate (e) {
events.trigger(this, "TimeUpdate", [e]);
if (this.lastCommand && this.lastCommand.Command === 'Play' && !this.isBuffering()) {
var currentTime = new Date();
var playAtTime = this.lastCommand.When;
var state = playbackManager.getPlayerState().PlayState;
// Estimate PositionTicks on server
var ServerPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) - this.timeDiff) * 10000;
// Measure delay that needs to be recovered
// diff might be caused by the player internally starting the playback
var diff = ServerPositionTicks - state.PositionTicks;
var diffMillis = diff / 10000;
this.playbackDiffMillis = diffMillis;
// console.debug("Syncplay onTimeUpdate", diffMillis, state.PositionTicks, ServerPositionTicks);
if (this.syncEnabled) {
var absDiffMillis = Math.abs(diffMillis);
// TODO: SpeedToSync sounds bad on songs
if (this.playbackRateSupported && absDiffMillis > MaxAcceptedDelaySpeedToSync && absDiffMillis < SyncMethodThreshold) {
// SpeedToSync method
var speed = 1 + diffMillis / SpeedUpToSyncTime;
this.currentPlayer.setPlaybackRate(speed);
this.syncEnabled = false;
this.showSyncIcon("SpeedToSync (x" + speed + ")");
this.syncTimeout = setTimeout(() => {
this.currentPlayer.setPlaybackRate(1);
this.syncEnabled = true;
this.clearSyncIcon();
}, SpeedUpToSyncTime);
} else if (absDiffMillis > MaxAcceptedDelaySkipToSync) {
// SkipToSync method
playbackManager.syncplay_seek(ServerPositionTicks);
this.syncEnabled = false;
this.showSyncIcon("SkipToSync");
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
this.clearSyncIcon();
}, this.syncMethodThreshold / 2);
}
}
}
}
/**
* Called when playback is resumed.
*/
onPlaying () {
// TODO: implement group wait
this.lastPlaybackWaiting = null;
events.trigger(this, "PlayerPlaying");
}
/**
* Called when playback is buffering.
*/
onWaiting () {
// TODO: implement group wait
if (!this.lastPlaybackWaiting) {
this.lastPlaybackWaiting = new Date();
}
events.trigger(this, "PlayerWaiting");
}
/**
* Gets playback buffering status.
* @returns {boolean} _true_ if player is buffering, _false_ otherwise.
*/
isBuffering () {
if (this.lastPlaybackWaiting === null) return false;
return (new Date() - this.lastPlaybackWaiting) > this.minBufferingThresholdMillis;
}
/**
* Binds to the player's events.
* @param {Object} player The player.
*/
bindToPlayer (player) {
if (player !== this.currentPlayer) {
this.releaseCurrentPlayer();
this.currentPlayer = player;
if (!player) return;
}
// TODO: remove this extra functions
const self = this;
this._onPlayPauseStateChanged = () => {
self.onPlayPauseStateChanged();
};
this._onPlayPauseStateChanged = (e) => {
self.onPlayPauseStateChanged(e);
};
this._onTimeUpdate = (e) => {
self.onTimeUpdate(e);
};
this._onPlaying = () => {
self.onPlaying();
};
this._onWaiting = () => {
self.onWaiting();
};
events.on(player, "pause", this._onPlayPauseStateChanged);
events.on(player, "unpause", this._onPlayPauseStateChanged);
events.on(player, "timeupdate", this._onTimeUpdate);
events.on(player, "playing", this._onPlaying);
events.on(player, "waiting", this._onWaiting);
this.playbackRateSupported = player.supports("PlaybackRate");
}
/**
* Removes the bindings to the current player's events.
*/
releaseCurrentPlayer () {
var player = this.currentPlayer;
if (player) {
events.off(player, "pause", this._onPlayPauseStateChanged);
events.off(player, "unpause", this._onPlayPauseStateChanged);
events.off(player, "timeupdate", this._onTimeUpdate);
events.off(player, "playing", this._onPlaying);
events.off(player, "waiting", this._onWaiting);
if (this.playbackRateSupported) {
player.setPlaybackRate(1);
}
this.currentPlayer = null;
this.playbackRateSupported = false;
}
}
/**
* Handles a group update from the server.
* @param {Object} cmd The group update.
* @param {Object} apiClient The ApiClient.
*/
processGroupUpdate (cmd, apiClient) {
switch (cmd.Type) {
case 'PrepareSession':
var serverId = apiClient.serverInfo().Id;
playbackManager.play({
ids: cmd.Data.ItemIds,
startPositionTicks: cmd.Data.StartPositionTicks,
mediaSourceId: cmd.Data.MediaSourceId,
audioStreamIndex: cmd.Data.AudioStreamIndex,
subtitleStreamIndex: cmd.Data.SubtitleStreamIndex,
startIndex: cmd.Data.StartIndex,
serverId: serverId
}).then(() => {
waitForEvent(this, "PlayerChange").then(() => {
playbackManager.pause();
var sessionId = getActivePlayerId();
if (!sessionId) {
console.error("Missing sessionId!");
toast({
text: "Failed to enable Syncplay!"
});
return;
}
// Sometimes JoinGroup fails, maybe because server hasn't been updated yet
setTimeout(() => {
apiClient.sendSyncplayCommand(sessionId, "JoinGroup", {
GroupId: cmd.GroupId
});
}, 500);
});
});
break;
case 'UserJoined':
toast({
text: globalize.translate('MessageSyncplayUserJoined', cmd.Data)
});
break;
case 'UserLeft':
toast({
text: globalize.translate('MessageSyncplayUserLeft', cmd.Data)
});
break;
case 'GroupJoined':
toast({
text: globalize.translate('MessageSyncplayEnabled')
});
// Enable Syncplay
this.syncplayEnabledAt = new Date(cmd.Data);
this.syncplayReady = false;
events.trigger(this, "SyncplayEnabled", [true]);
waitForEvent(this, "SyncplayReady").then(() => {
this.processCommand(this.queuedCommand, apiClient);
this.queuedCommand = null;
});
this.injectPlaybackManager();
this.startPing();
break;
case 'NotInGroup':
case 'GroupLeft':
toast({
text: globalize.translate('MessageSyncplayDisabled')
});
// Disable Syncplay
this.syncplayEnabledAt = null;
this.syncplayReady = false;
events.trigger(this, "SyncplayEnabled", [false]);
this.restorePlaybackManager();
this.stopPing();
break;
case 'GroupWait':
toast({
text: globalize.translate('MessageSyncplayGroupWait', cmd.Data)
});
break;
case 'KeepAlive':
break;
default:
console.error('processSyncplayGroupUpdate does not recognize: ' + cmd.Type);
break;
}
}
/**
* Handles a playback command from the server.
* @param {Object} cmd The playback command.
* @param {Object} apiClient The ApiClient.
*/
processCommand (cmd, apiClient) {
if (cmd === null) return;
if (!this.isSyncplayEnabled()) {
console.debug("Syncplay processCommand: ignoring command", cmd);
return;
}
if (!this.syncplayReady) {
console.debug("Syncplay processCommand: queued command", cmd);
this.queuedCommand = cmd;
return;
}
cmd.When = new Date(cmd.When);
if (cmd.When < this.syncplayEnabledAt) {
console.debug("Syncplay processCommand: ignoring old command", cmd);
return;
}
// Check if new command differs from last one
if (this.lastCommand &&
this.lastCommand.When === cmd.When &&
this.lastCommand.PositionTicks === cmd.PositionTicks &&
this.Command === cmd.Command
) {
console.debug("Syncplay processCommand: ignoring duplicate command", cmd);
return;
}
this.lastCommand = cmd;
console.log("Syncplay will", cmd.Command, "at", cmd.When, "PositionTicks", cmd.PositionTicks);
switch (cmd.Command) {
case 'Play':
this.schedulePlay(cmd.When, cmd.PositionTicks);
break;
case 'Pause':
this.schedulePause(cmd.When, cmd.PositionTicks);
break;
case 'Seek':
this.scheduleSeek(cmd.When, cmd.PositionTicks);
break;
default:
console.error('processSyncplayCommand does not recognize: ' + cmd.Type);
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.
*/
schedulePlay (playAtTime, positionTicks) {
this.clearScheduledCommand();
var currentTime = new Date();
var playAtTimeLocal = this.serverDateToLocal(playAtTime);
if (playAtTimeLocal > currentTime) {
var playTimeout = playAtTimeLocal - currentTime;
playbackManager.syncplay_seek(positionTicks);
this.scheduledCommand = setTimeout(() => {
playbackManager.syncplay_unpause();
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true
}, this.syncMethodThreshold / 2);
}, playTimeout);
// console.debug("Syncplay schedulePlay:", playTimeout);
} else {
// Group playback already started
var serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000;
playbackManager.syncplay_unpause();
playbackManager.syncplay_seek(serverPositionTicks);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true
}, this.syncMethodThreshold / 2);
}
}
/**
* Schedules a pause playback on the player at the specified clock time.
* @param {Date} pauseAtTime The server's UTC time at which to pause playback.
* @param {number} positionTicks The PositionTicks where player will be paused.
*/
schedulePause (pauseAtTime, positionTicks) {
this.clearScheduledCommand();
var currentTime = new Date();
var pauseAtTimeLocal = this.serverDateToLocal(pauseAtTime);
if (pauseAtTimeLocal > currentTime) {
var pauseTimeout = pauseAtTimeLocal - currentTime;
this.scheduledCommand = setTimeout(() => {
playbackManager.syncplay_pause();
setTimeout(() => {
playbackManager.syncplay_seek(positionTicks);
}, 800);
}, pauseTimeout);
} else {
playbackManager.syncplay_pause();
setTimeout(() => {
playbackManager.syncplay_seek(positionTicks);
}, 800);
}
}
/**
* Schedules a seek playback on the player at the specified clock time.
* @param {Date} pauseAtTime The server's UTC time at which to seek playback.
* @param {number} positionTicks The PositionTicks where player will be seeked.
*/
scheduleSeek (seekAtTime, positionTicks) {
this.schedulePause(seekAtTime, positionTicks);
}
/**
* Clears the current scheduled command.
*/
clearScheduledCommand () {
clearTimeout(this.scheduledCommand);
clearTimeout(this.syncTimeout);
this.syncEnabled = false;
if (this.currentPlayer) {
this.currentPlayer.setPlaybackRate(1);
}
this.clearSyncIcon();
}
/**
* Overrides some PlaybackManager's methods to intercept playback commands.
*/
injectPlaybackManager () {
if (!this.isSyncplayEnabled()) return;
if (playbackManager.syncplayEnabled) return;
// TODO: make this less hacky
playbackManager.syncplay_unpause = playbackManager.unpause;
playbackManager.syncplay_pause = playbackManager.pause;
playbackManager.syncplay_seek = playbackManager.seek;
playbackManager.unpause = this.playRequest;
playbackManager.pause = this.pauseRequest;
playbackManager.seek = this.seekRequest;
playbackManager.syncplayEnabled = true;
}
/**
* Restores original PlaybackManager's methods.
*/
restorePlaybackManager () {
if (this.isSyncplayEnabled()) return;
if (!playbackManager.syncplayEnabled) return;
playbackManager.unpause = playbackManager.syncplay_unpause;
playbackManager.pause = playbackManager.syncplay_pause;
playbackManager.seek = playbackManager.syncplay_seek;
playbackManager.syncplayEnabled = false;
}
/**
* Overrides PlaybackManager's unpause method.
*/
playRequest (player) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncplayCommand(sessionId, "PlayRequest");
}
/**
* Overrides PlaybackManager's pause method.
*/
pauseRequest (player) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncplayCommand(sessionId, "PauseRequest");
// Pause locally as well, to give the user some little control
playbackManager.syncplay_pause();
}
/**
* Overrides PlaybackManager's seek method.
*/
seekRequest (PositionTicks, player) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncplayCommand(sessionId, "SeekRequest", {
PositionTicks: PositionTicks
});
}
/**
* Computes time difference between this client's time and server's time.
* @param {Date} pingStartTime Local time when ping request started.
* @param {Date} pingEndTime Local time when ping request ended.
* @param {Date} serverTime Server UTC time at ping request.
*/
updateTimeDiff (pingStartTime, pingEndTime, serverTime) {
this.roundTripDuration = (pingEndTime - pingStartTime);
// The faster the response, the closer we are to the real timeDiff value
// localTime = pingStartTime + roundTripDuration / 2
// newTimeDiff = localTime - serverTime
var newTimeDiff = (pingStartTime - serverTime) + (this.roundTripDuration / 2);
// Initial setup
if (this.initTimeDiff === 0) {
this.timeDiff = newTimeDiff;
this.initTimeDiff++
return;
}
// As response time gets better, absolute value should decrease
var distanceFromZero = Math.abs(newTimeDiff);
var oldDistanceFromZero = Math.abs(this.timeDiff);
if (distanceFromZero < oldDistanceFromZero) {
this.timeDiff = newTimeDiff;
}
// Avoid overloading server
if (this.initTimeDiff >= GreedyPingCount) {
this.pingIntervalTimeout = PingIntervalTimeoutLowProfile;
} else {
this.initTimeDiff++;
}
// console.debug("Syncplay updateTimeDiff:", serverTime, this.timeDiff, this.roundTripDuration, newTimeDiff);
}
/**
* Schedules a ping request to the server. Used to compute time difference between client and server.
*/
requestPing () {
if (this.pingInterval === null && !this.pingStop) {
this.pingInterval = setTimeout(() => {
this.pingInterval = null;
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
var pingStartTime = new Date();
apiClient.sendSyncplayCommand(sessionId, "GetUtcTime").then((response) => {
var pingEndTime = new Date();
response.text().then((utcTime) => {
var serverTime = new Date(utcTime);
this.updateTimeDiff(pingStartTime, pingEndTime, serverTime);
// Alert user that ping is high
if (Math.abs(this.roundTripDuration) >= 1000) {
events.trigger(this, "SyncplayError", [true]);
} else {
events.trigger(this, "SyncplayError", [false]);
}
// Notify server of ping
apiClient.sendSyncplayCommand(sessionId, "KeepAlive", {
Ping: this.roundTripDuration / 2
});
if (this.notifySyncplayReady) {
this.syncplayReady = true;
events.trigger(this, "SyncplayReady");
this.notifySyncplayReady = false;
}
this.requestPing();
});
});
}, this.pingIntervalTimeout);
}
}
/**
* Starts the keep alive poller.
*/
startPing () {
this.notifySyncplayReady = true;
this.pingStop = false;
this.initTimeDiff = this.initTimeDiff > this.greedyPingCount ? 1 : this.initTimeDiff;
this.pingIntervalTimeout = this.pingIntervalTimeoutGreedy;
this.requestPing();
}
/**
* Stops the keep alive poller.
*/
stopPing () {
this.pingStop = true;
if (this.pingInterval !== null) {
clearTimeout(this.pingInterval);
this.pingInterval = null;
}
}
/**
* Converts server time to local time.
* @param {Date} server
* @returns {Date} Local time.
*/
serverDateToLocal (server) {
// local - server = diff
return new Date(server.getTime() + this.timeDiff);
}
/**
* Converts local time to server time.
* @param {Date} local
* @returns {Date} Server time.
*/
localDateToServer (local) {
// local - server = diff
return new Date(local.getTime() - this.timeDiff);
}
/**
* Gets Syncplay status.
* @returns {boolean} _true_ is user joined a group, _false_ otherwise.
*/
isSyncplayEnabled () {
return this.syncplayEnabledAt !== null ? true : false;
}
/**
* Gets Syncplay stats.
* @returns {Object} The Syncplay stats.
*/
getStats () {
return {
TimeDiff: this.timeDiff,
PlaybackDiff: this.playbackDiffMillis,
SyncMethod: this.syncMethod
}
}
/**
* Emits an event to update the Syncplay status icon.
*/
showSyncIcon (syncMethod) {
this.syncMethod = syncMethod;
events.trigger(this, "SyncplayError", [true]);
}
/**
* Emits an event to clear the Syncplay status icon.
*/
clearSyncIcon () {
this.syncMethod = "None";
events.trigger(this, "SyncplayError", [false]);
}
}
/** SyncplayManager singleton. */
export default new SyncplayManager();