mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Disable syncing after several attempts
Refactor syncplay manager Attempt fixing Safari issues (timeupdate event not firing)
This commit is contained in:
parent
9fabbd5746
commit
06e6c99c03
5 changed files with 437 additions and 118 deletions
|
@ -36,10 +36,18 @@ function showNewJoinGroupSelection(button, user, apiClient) {
|
||||||
sessionId = sessionId ? sessionId : "none";
|
sessionId = sessionId ? sessionId : "none";
|
||||||
const inSession = sessionId !== "none";
|
const inSession = sessionId !== "none";
|
||||||
const policy = user.localUser ? user.localUser.Policy : {};
|
const policy = user.localUser ? user.localUser.Policy : {};
|
||||||
|
let playingItemId;
|
||||||
|
try {
|
||||||
|
const playState = playbackManager.getPlayerState();
|
||||||
|
playingItemId = playState.NowPlayingItem.Id;
|
||||||
|
} catch (error) {
|
||||||
|
playingItemId = "";
|
||||||
|
}
|
||||||
|
|
||||||
apiClient.sendSyncplayCommand(sessionId, "ListGroups").then(function (response) {
|
apiClient.sendSyncplayCommand(sessionId, "ListGroups").then(function (response) {
|
||||||
response.json().then(function (groups) {
|
response.json().then(function (groups) {
|
||||||
var menuItems = groups.map(function (group) {
|
var menuItems = groups.map(function (group) {
|
||||||
|
// TODO: update running time if group is playing?
|
||||||
var name = datetime.getDisplayRunningTime(group.PositionTicks);
|
var name = datetime.getDisplayRunningTime(group.PositionTicks);
|
||||||
if (!inSession) {
|
if (!inSession) {
|
||||||
name = group.PlayingItemName;
|
name = group.PlayingItemName;
|
||||||
|
@ -90,7 +98,8 @@ function showNewJoinGroupSelection(button, user, apiClient) {
|
||||||
apiClient.sendSyncplayCommand(sessionId, "NewGroup");
|
apiClient.sendSyncplayCommand(sessionId, "NewGroup");
|
||||||
} else {
|
} else {
|
||||||
apiClient.sendSyncplayCommand(sessionId, "JoinGroup", {
|
apiClient.sendSyncplayCommand(sessionId, "JoinGroup", {
|
||||||
GroupId: id
|
GroupId: id,
|
||||||
|
PlayingItemId: playingItemId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, emptyCallback);
|
}, emptyCallback);
|
||||||
|
@ -114,6 +123,14 @@ function showNewJoinGroupSelection(button, user, apiClient) {
|
||||||
*/
|
*/
|
||||||
function showLeaveGroupSelection (button, user, apiClient) {
|
function showLeaveGroupSelection (button, user, apiClient) {
|
||||||
const sessionId = getActivePlayerId();
|
const sessionId = getActivePlayerId();
|
||||||
|
if (!sessionId) {
|
||||||
|
syncplayManager.signalError();
|
||||||
|
toast({
|
||||||
|
// TODO: translate
|
||||||
|
text: "Syncplay error occured."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const menuItems = [{
|
const menuItems = [{
|
||||||
|
|
|
@ -42,7 +42,9 @@ function getActivePlayerId() {
|
||||||
const MaxAcceptedDelaySpeedToSync = 50; // milliseconds, delay after which SpeedToSync is enabled
|
const MaxAcceptedDelaySpeedToSync = 50; // milliseconds, delay after which SpeedToSync is enabled
|
||||||
const MaxAcceptedDelaySkipToSync = 300; // milliseconds, delay after which SkipToSync is enabled
|
const MaxAcceptedDelaySkipToSync = 300; // milliseconds, delay after which SkipToSync is enabled
|
||||||
const SyncMethodThreshold = 2000; // milliseconds, switches between SpeedToSync or SkipToSync
|
const SyncMethodThreshold = 2000; // milliseconds, switches between SpeedToSync or SkipToSync
|
||||||
const SpeedUpToSyncTime = 1000; // milliseconds, duration in which the playback is sped up
|
const SpeedToSyncTime = 1000; // milliseconds, duration in which the playback is sped up
|
||||||
|
const MaxAttemptsSpeedToSync = 3; // attempts before disabling SpeedToSync
|
||||||
|
const MaxAttemptsSync = 5; // attempts before disabling syncing at all
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Time estimation
|
* Time estimation
|
||||||
|
@ -60,6 +62,9 @@ class SyncplayManager {
|
||||||
this.syncEnabled = false;
|
this.syncEnabled = false;
|
||||||
this.playbackDiffMillis = 0; // used for stats
|
this.playbackDiffMillis = 0; // used for stats
|
||||||
this.syncMethod = "None"; // used for stats
|
this.syncMethod = "None"; // used for stats
|
||||||
|
this.syncAttempts = 0;
|
||||||
|
this.lastSyncTime = new Date();
|
||||||
|
this.syncWatcherTimeout = null; // interval that watches playback time and syncs it
|
||||||
|
|
||||||
this.lastPlaybackWaiting = null; // used to determine if player's buffering
|
this.lastPlaybackWaiting = null; // used to determine if player's buffering
|
||||||
this.minBufferingThresholdMillis = 1000;
|
this.minBufferingThresholdMillis = 1000;
|
||||||
|
@ -86,7 +91,16 @@ class SyncplayManager {
|
||||||
events.on(playbackManager, "playerchange", () => {
|
events.on(playbackManager, "playerchange", () => {
|
||||||
this.onPlayerChange();
|
this.onPlayerChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
events.on(playbackManager, "playbackstart", (player, state) => {
|
||||||
|
events.trigger(this, 'PlaybackStart', [player, state]);
|
||||||
|
});
|
||||||
|
|
||||||
this.bindToPlayer(playbackManager.getCurrentPlayer());
|
this.bindToPlayer(playbackManager.getCurrentPlayer());
|
||||||
|
|
||||||
|
events.on(this, "TimeUpdate", (event) => {
|
||||||
|
this.syncPlaybackTime();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -110,53 +124,9 @@ class SyncplayManager {
|
||||||
* @param {Object} e The time update event.
|
* @param {Object} e The time update event.
|
||||||
*/
|
*/
|
||||||
onTimeUpdate (e) {
|
onTimeUpdate (e) {
|
||||||
|
// NOTICE: this event is unreliable, at least in Safari
|
||||||
|
// which just stops firing the event after a while.
|
||||||
events.trigger(this, "TimeUpdate", [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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -256,34 +226,7 @@ class SyncplayManager {
|
||||||
processGroupUpdate (cmd, apiClient) {
|
processGroupUpdate (cmd, apiClient) {
|
||||||
switch (cmd.Type) {
|
switch (cmd.Type) {
|
||||||
case 'PrepareSession':
|
case 'PrepareSession':
|
||||||
var serverId = apiClient.serverInfo().Id;
|
this.prepareSession(apiClient, cmd.GroupId, cmd.Data);
|
||||||
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;
|
break;
|
||||||
case 'UserJoined':
|
case 'UserJoined':
|
||||||
toast({
|
toast({
|
||||||
|
@ -296,31 +239,12 @@ class SyncplayManager {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'GroupJoined':
|
case 'GroupJoined':
|
||||||
toast({
|
const enabledAt = new Date(cmd.Data);
|
||||||
text: globalize.translate('MessageSyncplayEnabled')
|
this.enableSyncplay(apiClient, enabledAt, true);
|
||||||
});
|
|
||||||
// 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;
|
break;
|
||||||
case 'NotInGroup':
|
case 'NotInGroup':
|
||||||
case 'GroupLeft':
|
case 'GroupLeft':
|
||||||
toast({
|
this.disableSyncplay(true);
|
||||||
text: globalize.translate('MessageSyncplayDisabled')
|
|
||||||
});
|
|
||||||
// Disable Syncplay
|
|
||||||
this.syncplayEnabledAt = null;
|
|
||||||
this.syncplayReady = false;
|
|
||||||
events.trigger(this, "SyncplayEnabled", [false]);
|
|
||||||
this.restorePlaybackManager();
|
|
||||||
this.stopPing();
|
|
||||||
break;
|
break;
|
||||||
case 'GroupWait':
|
case 'GroupWait':
|
||||||
toast({
|
toast({
|
||||||
|
@ -355,8 +279,9 @@ class SyncplayManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.When = new Date(cmd.When);
|
cmd.When = new Date(cmd.When);
|
||||||
|
cmd.EmittedAt = new Date(cmd.EmitttedAt);
|
||||||
|
|
||||||
if (cmd.When < this.syncplayEnabledAt) {
|
if (cmd.EmitttedAt < this.syncplayEnabledAt) {
|
||||||
console.debug("Syncplay processCommand: ignoring old command", cmd);
|
console.debug("Syncplay processCommand: ignoring old command", cmd);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -390,6 +315,114 @@ class SyncplayManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares this client to join a group by loading the required content.
|
||||||
|
* @param {Object} apiClient The ApiClient.
|
||||||
|
* @param {string} groupId The group to join.
|
||||||
|
* @param {Object} sessionData Info about the content to load.
|
||||||
|
*/
|
||||||
|
prepareSession (apiClient, groupId, sessionData) {
|
||||||
|
var serverId = apiClient.serverInfo().Id;
|
||||||
|
playbackManager.play({
|
||||||
|
ids: sessionData.ItemIds,
|
||||||
|
startPositionTicks: sessionData.StartPositionTicks,
|
||||||
|
mediaSourceId: sessionData.MediaSourceId,
|
||||||
|
audioStreamIndex: sessionData.AudioStreamIndex,
|
||||||
|
subtitleStreamIndex: sessionData.SubtitleStreamIndex,
|
||||||
|
startIndex: sessionData.StartIndex,
|
||||||
|
serverId: serverId
|
||||||
|
}).then(() => {
|
||||||
|
// TODO: switch to PlaybackStart maybe?
|
||||||
|
waitForEvent(this, "PlayerChange").then(() => {
|
||||||
|
playbackManager.pause();
|
||||||
|
var sessionId = getActivePlayerId();
|
||||||
|
if (!sessionId) {
|
||||||
|
console.error("Missing sessionId!");
|
||||||
|
toast({
|
||||||
|
// TODO: translate
|
||||||
|
text: "Failed to enable Syncplay! Missing session id."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Get playing item id
|
||||||
|
let playingItemId;
|
||||||
|
try {
|
||||||
|
const playState = playbackManager.getPlayerState();
|
||||||
|
playingItemId = playState.NowPlayingItem.Id;
|
||||||
|
} catch (error) {
|
||||||
|
playingItemId = "";
|
||||||
|
}
|
||||||
|
// Sometimes JoinGroup fails, maybe because server hasn't been updated yet
|
||||||
|
setTimeout(() => {
|
||||||
|
apiClient.sendSyncplayCommand(sessionId, "JoinGroup", {
|
||||||
|
GroupId: groupId,
|
||||||
|
PlayingItemId: playingItemId
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast({
|
||||||
|
// TODO: translate
|
||||||
|
text: "Failed to enable Syncplay! Media error."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables Syncplay.
|
||||||
|
* @param {Object} apiClient The ApiClient.
|
||||||
|
* @param {Date} enabledAt When Syncplay has been enabled. Server side date.
|
||||||
|
* @param {boolean} showMessage Display message.
|
||||||
|
*/
|
||||||
|
enableSyncplay (apiClient, enabledAt, showMessage = false) {
|
||||||
|
this.syncplayEnabledAt = enabledAt;
|
||||||
|
this.syncplayReady = false;
|
||||||
|
events.trigger(this, "SyncplayEnabled", [true]);
|
||||||
|
waitForEvent(this, "SyncplayReady").then(() => {
|
||||||
|
this.processCommand(this.queuedCommand, apiClient);
|
||||||
|
this.queuedCommand = null;
|
||||||
|
});
|
||||||
|
this.injectPlaybackManager();
|
||||||
|
this.startPing();
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
toast({
|
||||||
|
text: globalize.translate('MessageSyncplayEnabled')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disables Syncplay.
|
||||||
|
* @param {boolean} showMessage Display message.
|
||||||
|
*/
|
||||||
|
disableSyncplay (showMessage = false) {
|
||||||
|
this.syncplayEnabledAt = null;
|
||||||
|
this.syncplayReady = false;
|
||||||
|
this.lastCommand = null;
|
||||||
|
this.queuedCommand = null;
|
||||||
|
this.syncEnabled = false;
|
||||||
|
events.trigger(this, "SyncplayEnabled", [false]);
|
||||||
|
this.restorePlaybackManager();
|
||||||
|
this.stopPing();
|
||||||
|
this.stopSyncWatcher();
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
toast({
|
||||||
|
text: globalize.translate('MessageSyncplayDisabled')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets Syncplay status.
|
||||||
|
* @returns {boolean} _true_ if user joined a group, _false_ otherwise.
|
||||||
|
*/
|
||||||
|
isSyncplayEnabled () {
|
||||||
|
return this.syncplayEnabledAt !== null ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedules a resume playback on the player at the specified clock time.
|
* 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 {Date} playAtTime The server's UTC time at which to resume playback.
|
||||||
|
@ -408,8 +441,9 @@ class SyncplayManager {
|
||||||
playbackManager.syncplay_unpause();
|
playbackManager.syncplay_unpause();
|
||||||
|
|
||||||
this.syncTimeout = setTimeout(() => {
|
this.syncTimeout = setTimeout(() => {
|
||||||
this.syncEnabled = true
|
this.syncEnabled = true;
|
||||||
}, this.syncMethodThreshold / 2);
|
this.startSyncWatcher();
|
||||||
|
}, SyncMethodThreshold / 2);
|
||||||
|
|
||||||
}, playTimeout);
|
}, playTimeout);
|
||||||
|
|
||||||
|
@ -421,8 +455,9 @@ class SyncplayManager {
|
||||||
playbackManager.syncplay_seek(serverPositionTicks);
|
playbackManager.syncplay_seek(serverPositionTicks);
|
||||||
|
|
||||||
this.syncTimeout = setTimeout(() => {
|
this.syncTimeout = setTimeout(() => {
|
||||||
this.syncEnabled = true
|
this.syncEnabled = true;
|
||||||
}, this.syncMethodThreshold / 2);
|
this.startSyncWatcher();
|
||||||
|
}, SyncMethodThreshold / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -471,6 +506,7 @@ class SyncplayManager {
|
||||||
clearTimeout(this.syncTimeout);
|
clearTimeout(this.syncTimeout);
|
||||||
|
|
||||||
this.syncEnabled = false;
|
this.syncEnabled = false;
|
||||||
|
this.stopSyncWatcher();
|
||||||
if (this.currentPlayer) {
|
if (this.currentPlayer) {
|
||||||
this.currentPlayer.setPlaybackRate(1);
|
this.currentPlayer.setPlaybackRate(1);
|
||||||
}
|
}
|
||||||
|
@ -587,6 +623,15 @@ class SyncplayManager {
|
||||||
var apiClient = connectionManager.currentApiClient();
|
var apiClient = connectionManager.currentApiClient();
|
||||||
var sessionId = getActivePlayerId();
|
var sessionId = getActivePlayerId();
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
this.signalError();
|
||||||
|
toast({
|
||||||
|
// TODO: translate
|
||||||
|
text: "Syncplay error occured."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var pingStartTime = new Date();
|
var pingStartTime = new Date();
|
||||||
apiClient.sendSyncplayCommand(sessionId, "GetUtcTime").then((response) => {
|
apiClient.sendSyncplayCommand(sessionId, "GetUtcTime").then((response) => {
|
||||||
var pingEndTime = new Date();
|
var pingEndTime = new Date();
|
||||||
|
@ -614,6 +659,13 @@ class SyncplayManager {
|
||||||
|
|
||||||
this.requestPing();
|
this.requestPing();
|
||||||
});
|
});
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
this.signalError();
|
||||||
|
toast({
|
||||||
|
// TODO: translate
|
||||||
|
text: "Syncplay error occured."
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}, this.pingIntervalTimeout);
|
}, this.pingIntervalTimeout);
|
||||||
|
@ -627,7 +679,7 @@ class SyncplayManager {
|
||||||
this.notifySyncplayReady = true;
|
this.notifySyncplayReady = true;
|
||||||
this.pingStop = false;
|
this.pingStop = false;
|
||||||
this.initTimeDiff = this.initTimeDiff > this.greedyPingCount ? 1 : this.initTimeDiff;
|
this.initTimeDiff = this.initTimeDiff > this.greedyPingCount ? 1 : this.initTimeDiff;
|
||||||
this.pingIntervalTimeout = this.pingIntervalTimeoutGreedy;
|
this.pingIntervalTimeout = PingIntervalTimeoutGreedy;
|
||||||
|
|
||||||
this.requestPing();
|
this.requestPing();
|
||||||
}
|
}
|
||||||
|
@ -643,6 +695,159 @@ class SyncplayManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to sync playback time with estimated server time.
|
||||||
|
*
|
||||||
|
* When sync is enabled, the following will be checked:
|
||||||
|
* - check if local playback time is close enough to the server playback time
|
||||||
|
* If it is not, then a playback time sync will be attempted.
|
||||||
|
* Two methods 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.
|
||||||
|
*/
|
||||||
|
syncPlaybackTime () {
|
||||||
|
// Attempt to sync only when media is playing.
|
||||||
|
if (!this.lastCommand || this.lastCommand.Command !== 'Play' || this.isBuffering()) return;
|
||||||
|
|
||||||
|
const currentTime = new Date();
|
||||||
|
|
||||||
|
// Avoid overloading the browser
|
||||||
|
const elapsed = currentTime - this.lastSyncTime;
|
||||||
|
if (elapsed < SyncMethodThreshold / 2) return;
|
||||||
|
this.lastSyncTime = currentTime;
|
||||||
|
this.notifySyncWatcher();
|
||||||
|
|
||||||
|
const playAtTime = this.lastCommand.When;
|
||||||
|
|
||||||
|
const CurrentPositionTicks = playbackManager.currentTime();
|
||||||
|
// Estimate PositionTicks on server
|
||||||
|
const 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
|
||||||
|
const diff = ServerPositionTicks - CurrentPositionTicks;
|
||||||
|
const diffMillis = diff / 10000;
|
||||||
|
|
||||||
|
this.playbackDiffMillis = diffMillis;
|
||||||
|
|
||||||
|
// console.debug("Syncplay onTimeUpdate", diffMillis, CurrentPositionTicks, ServerPositionTicks);
|
||||||
|
|
||||||
|
if (this.syncEnabled) {
|
||||||
|
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
|
||||||
|
if (this.playbackRateSupported && absDiffMillis > MaxAcceptedDelaySpeedToSync && absDiffMillis < SyncMethodThreshold) {
|
||||||
|
// Disable SpeedToSync if it keeps failing
|
||||||
|
if (this.syncAttempts > MaxAttemptsSpeedToSync) {
|
||||||
|
this.playbackRateSupported = false;
|
||||||
|
}
|
||||||
|
// SpeedToSync method
|
||||||
|
const speed = 1 + diffMillis / SpeedToSyncTime;
|
||||||
|
|
||||||
|
this.currentPlayer.setPlaybackRate(speed);
|
||||||
|
this.syncEnabled = false;
|
||||||
|
this.syncAttempts++;
|
||||||
|
this.showSyncIcon("SpeedToSync (x" + speed + ")");
|
||||||
|
|
||||||
|
this.syncTimeout = setTimeout(() => {
|
||||||
|
this.currentPlayer.setPlaybackRate(1);
|
||||||
|
this.syncEnabled = true;
|
||||||
|
this.clearSyncIcon();
|
||||||
|
}, SpeedToSyncTime);
|
||||||
|
} else if (absDiffMillis > MaxAcceptedDelaySkipToSync) {
|
||||||
|
// Disable SkipToSync if it keeps failing
|
||||||
|
if (this.syncAttempts > MaxAttemptsSync) {
|
||||||
|
this.syncEnabled = false;
|
||||||
|
this.showSyncIcon("Sync disabled (too many attempts)");
|
||||||
|
}
|
||||||
|
// SkipToSync method
|
||||||
|
playbackManager.syncplay_seek(ServerPositionTicks);
|
||||||
|
this.syncEnabled = false;
|
||||||
|
this.syncAttempts++;
|
||||||
|
this.showSyncIcon("SkipToSync (" + this.syncAttempts + ")");
|
||||||
|
|
||||||
|
this.syncTimeout = setTimeout(() => {
|
||||||
|
this.syncEnabled = true;
|
||||||
|
this.clearSyncIcon();
|
||||||
|
}, SyncMethodThreshold / 2);
|
||||||
|
} else {
|
||||||
|
// Playback is synced
|
||||||
|
if (this.syncAttempts > 0) {
|
||||||
|
// console.debug("Playback has been synced after", this.syncAttempts, "attempts.");
|
||||||
|
}
|
||||||
|
this.syncAttempts = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals the worker to start watching sync. Also creates the worker if needed.
|
||||||
|
*
|
||||||
|
* This additional fail-safe has been added because on Safari the timeupdate event fails after a while.
|
||||||
|
*/
|
||||||
|
startSyncWatcher () {
|
||||||
|
// SPOILER ALERT: this idea fails too on Safari... Keeping it here for future investigations
|
||||||
|
return;
|
||||||
|
if (window.Worker) {
|
||||||
|
// Start worker if needed
|
||||||
|
if (!this.worker) {
|
||||||
|
this.worker = new Worker("workers/syncplay/syncplay.worker.js");
|
||||||
|
this.worker.onmessage = (event) => {
|
||||||
|
const message = event.data;
|
||||||
|
switch (message.type) {
|
||||||
|
case "TriggerSync":
|
||||||
|
// TODO: player state might not reflect the real playback position,
|
||||||
|
// thus calling syncPlaybackTime outside a timeupdate event might not really sync to the right point
|
||||||
|
this.syncPlaybackTime();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error("Syncplay: unknown message from worker:", message.type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.worker.onerror = (event) => {
|
||||||
|
console.error("Syncplay: worker error", event);
|
||||||
|
};
|
||||||
|
this.worker.onmessageerror = (event) => {
|
||||||
|
console.error("Syncplay: worker message error", event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Start watcher
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "StartSyncWatcher",
|
||||||
|
data: {
|
||||||
|
interval: SyncMethodThreshold / 2,
|
||||||
|
threshold: SyncMethodThreshold
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.debug("Syncplay: workers not supported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals the worker to stop watching sync.
|
||||||
|
*/
|
||||||
|
stopSyncWatcher () {
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "StopSyncWatcher"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals new state to worker.
|
||||||
|
*/
|
||||||
|
notifySyncWatcher () {
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "UpdateLastSyncTime",
|
||||||
|
data: this.lastSyncTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts server time to local time.
|
* Converts server time to local time.
|
||||||
* @param {Date} server The time to convert.
|
* @param {Date} server The time to convert.
|
||||||
|
@ -663,14 +868,6 @@ class SyncplayManager {
|
||||||
return new Date(local.getTime() - this.timeDiff);
|
return new Date(local.getTime() - this.timeDiff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets Syncplay status.
|
|
||||||
* @returns {boolean} _true_ if user joined a group, _false_ otherwise.
|
|
||||||
*/
|
|
||||||
isSyncplayEnabled () {
|
|
||||||
return this.syncplayEnabledAt !== null ? true : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets Syncplay stats.
|
* Gets Syncplay stats.
|
||||||
* @returns {Object} The Syncplay stats.
|
* @returns {Object} The Syncplay stats.
|
||||||
|
@ -698,6 +895,13 @@ class SyncplayManager {
|
||||||
this.syncMethod = "None";
|
this.syncMethod = "None";
|
||||||
events.trigger(this, "SyncplayError", [false]);
|
events.trigger(this, "SyncplayError", [false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals an error state, which disables and resets Syncplay for a new session.
|
||||||
|
*/
|
||||||
|
signalError () {
|
||||||
|
this.disableSyncplay();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SyncplayManager singleton. */
|
/** SyncplayManager singleton. */
|
||||||
|
|
90
src/workers/syncplay/syncplay.worker.js
Normal file
90
src/workers/syncplay/syncplay.worker.js
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
var SyncTimeThreshold = 2000; // milliseconds, overwritten by startSyncWatcher
|
||||||
|
var SyncWatcherInterval = 1000; // milliseconds, overwritten by startSyncWatcher
|
||||||
|
var lastSyncTime = new Date(); // internal state
|
||||||
|
var syncWatcher; // holds value from setInterval
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a message to the UI worker.
|
||||||
|
* @param {string} type
|
||||||
|
* @param {*} data
|
||||||
|
*/
|
||||||
|
function sendMessage (type, data) {
|
||||||
|
postMessage({
|
||||||
|
type: type,
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the state.
|
||||||
|
* @param {Date} syncTime The new state.
|
||||||
|
*/
|
||||||
|
function updateLastSyncTime (syncTime) {
|
||||||
|
lastSyncTime = syncTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts sync watcher.
|
||||||
|
* @param {Object} options Additional options to configure the watcher, like _interval_ and _threshold_.
|
||||||
|
*/
|
||||||
|
function startSyncWatcher(options) {
|
||||||
|
stopSyncWatcher();
|
||||||
|
if (options) {
|
||||||
|
if (options.interval) {
|
||||||
|
SyncWatcherInterval = options.interval;
|
||||||
|
}
|
||||||
|
if (options.threshold) {
|
||||||
|
SyncTimeThreshold = options.threshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncWatcher = setInterval(syncWatcherCallback, SyncWatcherInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops sync watcher.
|
||||||
|
*/
|
||||||
|
function stopSyncWatcher () {
|
||||||
|
if (syncWatcher) {
|
||||||
|
clearInterval(syncWatcher);
|
||||||
|
syncWatcher = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oversees playback sync and makes sure that it gets called regularly.
|
||||||
|
*/
|
||||||
|
function syncWatcherCallback () {
|
||||||
|
const currentTime = new Date();
|
||||||
|
const elapsed = currentTime - lastSyncTime;
|
||||||
|
if (elapsed > SyncTimeThreshold) {
|
||||||
|
sendMessage("TriggerSync");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles messages from UI worker.
|
||||||
|
* @param {MessageEvent} event The message to handle.
|
||||||
|
*/
|
||||||
|
function handleMessage (event) {
|
||||||
|
const message = event.data;
|
||||||
|
switch (message.type) {
|
||||||
|
case "UpdateLastSyncTime":
|
||||||
|
updateLastSyncTime(message.data);
|
||||||
|
break;
|
||||||
|
case "StartSyncWatcher":
|
||||||
|
startSyncWatcher(message.data);
|
||||||
|
break;
|
||||||
|
case "StopSyncWatcher":
|
||||||
|
stopSyncWatcher();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error("Unknown message type:", message.type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
addEventListener("message", function (event) {
|
||||||
|
handleMessage(event);
|
||||||
|
});
|
|
@ -48,6 +48,10 @@ module.exports = merge(common, {
|
||||||
{
|
{
|
||||||
test: /\.(wav)$/i,
|
test: /\.(wav)$/i,
|
||||||
use: ["file-loader"]
|
use: ["file-loader"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.worker.js$/,
|
||||||
|
use: ["worker"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,10 @@ module.exports = merge(common, {
|
||||||
{
|
{
|
||||||
test: /\.(wav)$/i,
|
test: /\.(wav)$/i,
|
||||||
use: ["file-loader"]
|
use: ["file-loader"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.worker.js$/,
|
||||||
|
use: ["worker"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue