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

Implement NTP like time sync

This commit is contained in:
gion 2020-04-16 16:05:04 +02:00
parent 06e6c99c03
commit 5342b90a56
5 changed files with 236 additions and 156 deletions

View file

@ -332,8 +332,8 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplay
var stats = syncplayManager.getStats(); var stats = syncplayManager.getStats();
syncStats.push({ syncStats.push({
label: globalize.translate("LabelSyncplayTimeDiff"), label: globalize.translate("LabelSyncplayTimeOffset"),
value: stats.TimeDiff + "ms" value: stats.TimeOffset + "ms"
}); });
syncStats.push({ syncStats.push({

View file

@ -8,6 +8,7 @@
import events from 'events'; import events from 'events';
import connectionManager from 'connectionManager'; import connectionManager from 'connectionManager';
import playbackManager from 'playbackManager'; import playbackManager from 'playbackManager';
import timeSyncManager from 'timeSyncManager';
import toast from 'toast'; import toast from 'toast';
import globalize from 'globalize'; import globalize from 'globalize';
@ -80,11 +81,7 @@ class SyncplayManager {
this.scheduledCommand = null; this.scheduledCommand = null;
this.syncTimeout = null; this.syncTimeout = null;
this.pingStop = true; this.timeOffsetWithServer = 0; // server time minus local time
this.pingIntervalTimeout = PingIntervalTimeoutGreedy;
this.pingInterval = null;
this.initTimeDiff = 0; // number of pings
this.timeDiff = 0; // local time minus server time
this.roundTripDuration = 0; this.roundTripDuration = 0;
this.notifySyncplayReady = false; this.notifySyncplayReady = false;
@ -101,6 +98,17 @@ class SyncplayManager {
events.on(this, "TimeUpdate", (event) => { events.on(this, "TimeUpdate", (event) => {
this.syncPlaybackTime(); this.syncPlaybackTime();
}); });
events.on(timeSyncManager, "Update", (event, timeOffset, ping) => {
this.timeOffsetWithServer = timeOffset;
this.roundTripDuration = ping * 2;
if (this.notifySyncplayReady) {
this.syncplayReady = true;
events.trigger(this, "SyncplayReady");
this.notifySyncplayReady = false;
}
});
} }
/** /**
@ -377,14 +385,17 @@ class SyncplayManager {
*/ */
enableSyncplay (apiClient, enabledAt, showMessage = false) { enableSyncplay (apiClient, enabledAt, showMessage = false) {
this.syncplayEnabledAt = enabledAt; this.syncplayEnabledAt = enabledAt;
this.syncplayReady = false; this.injectPlaybackManager();
events.trigger(this, "SyncplayEnabled", [true]); events.trigger(this, "SyncplayEnabled", [true]);
waitForEvent(this, "SyncplayReady").then(() => { waitForEvent(this, "SyncplayReady").then(() => {
this.processCommand(this.queuedCommand, apiClient); this.processCommand(this.queuedCommand, apiClient);
this.queuedCommand = null; this.queuedCommand = null;
}); });
this.injectPlaybackManager(); this.syncplayReady = false;
this.startPing(); this.notifySyncplayReady = true;
timeSyncManager.forceUpdate();
if (showMessage) { if (showMessage) {
toast({ toast({
@ -405,7 +416,6 @@ class SyncplayManager {
this.syncEnabled = false; this.syncEnabled = false;
events.trigger(this, "SyncplayEnabled", [false]); events.trigger(this, "SyncplayEnabled", [false]);
this.restorePlaybackManager(); this.restorePlaybackManager();
this.stopPing();
this.stopSyncWatcher(); this.stopSyncWatcher();
if (showMessage) { if (showMessage) {
@ -431,7 +441,7 @@ class SyncplayManager {
schedulePlay (playAtTime, positionTicks) { schedulePlay (playAtTime, positionTicks) {
this.clearScheduledCommand(); this.clearScheduledCommand();
var currentTime = new Date(); var currentTime = new Date();
var playAtTimeLocal = this.serverDateToLocal(playAtTime); var playAtTimeLocal = timeSyncManager.serverDateToLocal(playAtTime);
if (playAtTimeLocal > currentTime) { if (playAtTimeLocal > currentTime) {
var playTimeout = playAtTimeLocal - currentTime; var playTimeout = playAtTimeLocal - currentTime;
@ -469,7 +479,7 @@ class SyncplayManager {
schedulePause (pauseAtTime, positionTicks) { schedulePause (pauseAtTime, positionTicks) {
this.clearScheduledCommand(); this.clearScheduledCommand();
var currentTime = new Date(); var currentTime = new Date();
var pauseAtTimeLocal = this.serverDateToLocal(pauseAtTime); var pauseAtTimeLocal = timeSyncManager.serverDateToLocal(pauseAtTime);
if (pauseAtTimeLocal > currentTime) { if (pauseAtTimeLocal > currentTime) {
var pauseTimeout = pauseAtTimeLocal - currentTime; var pauseTimeout = pauseAtTimeLocal - currentTime;
@ -575,126 +585,6 @@ class SyncplayManager {
}); });
} }
/**
* 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();
if (!sessionId) {
this.signalError();
toast({
// TODO: translate
text: "Syncplay error occured."
});
return;
}
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();
});
}).catch((error) => {
console.error(error);
this.signalError();
toast({
// TODO: translate
text: "Syncplay error occured."
});
});
}, 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 = PingIntervalTimeoutGreedy;
this.requestPing();
}
/**
* Stops the keep alive poller.
*/
stopPing () {
this.pingStop = true;
if (this.pingInterval !== null) {
clearTimeout(this.pingInterval);
this.pingInterval = null;
}
}
/** /**
* Attempts to sync playback time with estimated server time. * Attempts to sync playback time with estimated server time.
* *
@ -722,7 +612,7 @@ class SyncplayManager {
const CurrentPositionTicks = playbackManager.currentTime(); const CurrentPositionTicks = playbackManager.currentTime();
// Estimate PositionTicks on server // Estimate PositionTicks on server
const ServerPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) - this.timeDiff) * 10000; const ServerPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) + this.timeOffsetWithServer) * 10000;
// Measure delay that needs to be recovered // Measure delay that needs to be recovered
// diff might be caused by the player internally starting the playback // diff might be caused by the player internally starting the playback
const diff = ServerPositionTicks - CurrentPositionTicks; const diff = ServerPositionTicks - CurrentPositionTicks;
@ -848,33 +738,13 @@ class SyncplayManager {
} }
} }
/**
* Converts server time to local time.
* @param {Date} server The time to convert.
* @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 The time to convert.
* @returns {Date} Server time.
*/
localDateToServer (local) {
// local - server = diff
return new Date(local.getTime() - this.timeDiff);
}
/** /**
* Gets Syncplay stats. * Gets Syncplay stats.
* @returns {Object} The Syncplay stats. * @returns {Object} The Syncplay stats.
*/ */
getStats () { getStats () {
return { return {
TimeDiff: this.timeDiff, TimeOffset: this.timeOffsetWithServer,
PlaybackDiff: this.playbackDiffMillis, PlaybackDiff: this.playbackDiffMillis,
SyncMethod: this.syncMethod SyncMethod: this.syncMethod
} }

View file

@ -0,0 +1,209 @@
/* eslint-disable indent */
/**
* Module that manages time syncing with server.
* @module components/syncplay/timeSyncManager
*/
import events from 'events';
import connectionManager from 'connectionManager';
/**
* 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} t0 Client's timestamp of the request transmission
* @param {Date} t1 Server's timestamp of the request reception
* @param {Date} t2 Server's timestamp of the response transmission
* @param {Date} t3 Client's timestamp of the response reception
*/
constructor(t0, t1, t2, t3) {
this.t0 = t0.getTime();
this.t1 = t1.getTime();
this.t2 = t2.getTime();
this.t3 = t3.getTime();
}
/**
* Time offset from server.
*/
getOffset () {
return ((this.t1 - this.t0) + (this.t2 - this.t3)) / 2;
}
/**
* Get round-trip delay.
*/
getDelay () {
return (this.t3 - this.t0) - (this.t2 - this.t1);
}
/**
* Get ping time.
*/
getPing () {
return this.getDelay() / 2;
}
}
/**
* Class that manages time syncing with server.
*/
class TimeSyncManager {
constructor() {
this.pingStop = true;
this.pollingInterval = PollingIntervalGreedy;
this.poller = null;
this.pings = 0; // number of pings
this.measurement = null; // current time sync
this.measurements = [];
this.startPing();
}
/**
* Gets status of time sync.
* @returns {boolean} _true_ if a measurement has been done, _false_ otherwise.
*/
isReady() {
return this.measurement ? true : false;
}
/**
* Gets time offset with server.
* @returns {number} The time offset.
*/
getTimeOffset () {
return this.measurement ? this.measurement.getOffset() : 0;
}
/**
* Gets ping time to server.
* @returns {number} The ping time.
*/
getPing () {
return this.measurement ? this.measurement.getPing() : 0;
}
/**
* Updates time offset between server and client.
* @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 server. Triggers time offset update.
*/
requestPing() {
if (!this.poller) {
this.poller = setTimeout(() => {
this.poller = null;
const apiClient = connectionManager.currentApiClient();
const t0 = new Date(); // pingStartTime
apiClient.getServerTime().then((response) => {
const t3 = new Date(); // pingEndTime
response.json().then((data) => {
const t1 = new Date(data.RequestReceptionTime); // request received
const t2 = new Date(data.ResponseTransmissionTime); // response sent
const measurement = new Measurement(t0, t1, t2, t3);
this.updateTimeOffset(measurement);
// Avoid overloading server
if (this.pings >= GreedyPingCount) {
this.pollingInterval = PollingIntervalLowProfile;
} else {
this.pings++;
}
events.trigger(this, "Update", [this.getTimeOffset(), this.getPing()]);
});
}).catch((error) => {
console.error(error);
events.trigger(this, "Error", [error]);
}).finally(() => {
this.requestPing();
});
}, this.pollingInterval);
}
}
/**
* Drops accumulated measurements.
*/
resetMeasurements () {
this.measurement = null;
this.measurements = [];
}
/**
* Starts the time poller.
*/
startPing() {
this.requestPing();
}
/**
* Stops the time poller.
*/
stopPing() {
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 server time to local time.
* @param {Date} server The time to convert.
* @returns {Date} Local time.
*/
serverDateToLocal(server) {
// server - local = offset
return new Date(server.getTime() + this.getTimeOffset());
}
/**
* Converts local time to server time.
* @param {Date} local The time to convert.
* @returns {Date} Server time.
*/
localDateToServer(local) {
// server - local = offset
return new Date(local.getTime() - this.getTimeOffset());
}
}
/** TimeSyncManager singleton. */
export default new TimeSyncManager();

View file

@ -824,6 +824,7 @@ var AppInfo = {};
define('playbackSettings', [componentsPath + '/playbacksettings/playbacksettings'], returnFirstDependency); define('playbackSettings', [componentsPath + '/playbacksettings/playbacksettings'], returnFirstDependency);
define('homescreenSettings', [componentsPath + '/homescreensettings/homescreensettings'], returnFirstDependency); define('homescreenSettings', [componentsPath + '/homescreensettings/homescreensettings'], returnFirstDependency);
define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager); define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager);
define('timeSyncManager', [componentsPath + '/syncplay/timeSyncManager'], returnDefault);
define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnDefault); define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnDefault);
define('playbackPermissionManager', [componentsPath + '/syncplay/playbackPermissionManager'], returnDefault); define('playbackPermissionManager', [componentsPath + '/syncplay/playbackPermissionManager'], returnDefault);
define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager); define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager);

View file

@ -855,7 +855,7 @@
"LabelSubtitlePlaybackMode": "Subtitle mode:", "LabelSubtitlePlaybackMode": "Subtitle mode:",
"LabelSubtitles": "Subtitles", "LabelSubtitles": "Subtitles",
"LabelSupportedMediaTypes": "Supported Media Types:", "LabelSupportedMediaTypes": "Supported Media Types:",
"LabelSyncplayTimeDiff": "Time difference with server:", "LabelSyncplayTimeOffset": "Time offset with server:",
"LabelSyncplayPlaybackDiff": "Playback time difference:", "LabelSyncplayPlaybackDiff": "Playback time difference:",
"LabelSyncplaySyncMethod": "Sync method:", "LabelSyncplaySyncMethod": "Sync method:",
"LabelSyncplayNewGroup": "New group", "LabelSyncplayNewGroup": "New group",