From 6c18b655e0b4823e4b7a6377cc1ebc4fbc27ab12 Mon Sep 17 00:00:00 2001 From: gion Date: Wed, 1 Apr 2020 17:53:14 +0200 Subject: [PATCH 01/14] Implement syncplay frontend --- src/assets/css/videoosd.css | 2 +- src/components/htmlaudioplayer/plugin.js | 42 ++ src/components/htmlvideoplayer/plugin.js | 25 + src/components/playback/playbackmanager.js | 21 + src/components/playerstats/playerstats.js | 31 +- src/components/serverNotifications.js | 6 +- src/components/syncplay/groupSelectionMenu.js | 140 ++++ src/components/syncplay/syncplaymanager.js | 600 ++++++++++++++++++ src/scripts/librarymenu.js | 36 +- src/scripts/site.js | 1 + src/strings/en-us.json | 15 + 11 files changed, 915 insertions(+), 4 deletions(-) create mode 100644 src/components/syncplay/groupSelectionMenu.js create mode 100644 src/components/syncplay/syncplaymanager.js diff --git a/src/assets/css/videoosd.css b/src/assets/css/videoosd.css index f4f198325b..50cb41021b 100644 --- a/src/assets/css/videoosd.css +++ b/src/assets/css/videoosd.css @@ -30,7 +30,7 @@ opacity: 0; } -.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton) { +.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton):not(.headerSyncButton) { display: none; } diff --git a/src/components/htmlaudioplayer/plugin.js b/src/components/htmlaudioplayer/plugin.js index c3a5484aca..2580481051 100644 --- a/src/components/htmlaudioplayer/plugin.js +++ b/src/components/htmlaudioplayer/plugin.js @@ -171,6 +171,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp elem.addEventListener('pause', onPause); elem.addEventListener('playing', onPlaying); elem.addEventListener('play', onPlay); + elem.addEventListener('waiting', onWaiting); } function unBindEvents(elem) { @@ -180,6 +181,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp elem.removeEventListener('pause', onPause); elem.removeEventListener('playing', onPlaying); elem.removeEventListener('play', onPlay); + elem.removeEventListener('waiting', onWaiting); } self.stop = function (destroyPlayer) { @@ -294,6 +296,10 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp events.trigger(self, 'pause'); } + function onWaiting() { + events.trigger(self, 'waiting'); + } + function onError() { var errorCode = this.error ? (this.error.code || 0) : 0; @@ -450,6 +456,21 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp return false; }; + HtmlAudioPlayer.prototype.setPlaybackRate = function (value) { + var mediaElement = this._mediaElement; + if (mediaElement) { + mediaElement.playbackRate = value; + } + }; + + HtmlAudioPlayer.prototype.getPlaybackRate = function () { + var mediaElement = this._mediaElement; + if (mediaElement) { + return mediaElement.playbackRate; + } + return null; + }; + HtmlAudioPlayer.prototype.setVolume = function (val) { var mediaElement = this._mediaElement; if (mediaElement) { @@ -493,5 +514,26 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp }; + var supportedFeatures; + + function getSupportedFeatures() { + var list = []; + var audio = document.createElement('audio'); + + if (typeof audio.playbackRate === "number") { + list.push("PlaybackRate"); + } + + return list; + } + + HtmlAudioPlayer.prototype.supports = function (feature) { + if (!supportedFeatures) { + supportedFeatures = getSupportedFeatures(); + } + + return supportedFeatures.indexOf(feature) !== -1; + }; + return HtmlAudioPlayer; }); diff --git a/src/components/htmlvideoplayer/plugin.js b/src/components/htmlvideoplayer/plugin.js index f87fd19462..064e4155ee 100644 --- a/src/components/htmlvideoplayer/plugin.js +++ b/src/components/htmlvideoplayer/plugin.js @@ -785,6 +785,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa videoElement.removeEventListener('play', onPlay); videoElement.removeEventListener('click', onClick); videoElement.removeEventListener('dblclick', onDblClick); + videoElement.removeEventListener('waiting', onWaiting); videoElement.parentNode.removeChild(videoElement); } @@ -915,6 +916,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa events.trigger(self, 'pause'); } + function onWaiting() { + events.trigger(self, 'waiting'); + } + function onError() { var errorCode = this.error ? (this.error.code || 0) : 0; var errorMessage = this.error ? (this.error.message || '') : ''; @@ -1348,6 +1353,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa videoElement.addEventListener('play', onPlay); videoElement.addEventListener('click', onClick); videoElement.addEventListener('dblclick', onDblClick); + videoElement.addEventListener('waiting', onWaiting); document.body.insertBefore(dlg, document.body.firstChild); videoDialog = dlg; @@ -1436,6 +1442,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa list.push('AirPlay'); } + if (typeof video.playbackRate === "number") { + list.push("PlaybackRate"); + } + list.push('SetBrightness'); list.push('SetAspectRatio'); @@ -1656,6 +1666,21 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa return false; }; + HtmlVideoPlayer.prototype.setPlaybackRate = function (value) { + var mediaElement = this._mediaElement; + if (mediaElement) { + mediaElement.playbackRate = value; + } + }; + + HtmlVideoPlayer.prototype.getPlaybackRate = function () { + var mediaElement = this._mediaElement; + if (mediaElement) { + return mediaElement.playbackRate; + } + return null; + }; + HtmlVideoPlayer.prototype.setVolume = function (val) { var mediaElement = this._mediaElement; if (mediaElement) { diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index ee85f9acb1..923092cd3d 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -3777,6 +3777,24 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla } }; + PlaybackManager.prototype.setPlaybackRate = function (value, player) { + player = player || this._currentPlayer; + + if (player) { + player.setPlaybackRate(value); + } + }; + + PlaybackManager.prototype.getPlaybackRate = function (player) { + player = player || this._currentPlayer; + + if (player) { + return player.getPlaybackRate(val); + } + + return null; + }; + PlaybackManager.prototype.instantMix = function (item, player) { player = player || this._currentPlayer; @@ -3887,6 +3905,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla if (player.supports('SetAspectRatio')) { list.push('SetAspectRatio'); } + if (player.supports('PlaybackRate')) { + list.push('PlaybackRate'); + } } return list; diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index c0fb369c6c..ad4ce960bf 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -1,4 +1,4 @@ -define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, playMethodHelper, layoutManager, serverNotifications) { +define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplayManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, syncplayManager, playMethodHelper, layoutManager, serverNotifications) { 'use strict'; function init(instance) { @@ -327,6 +327,28 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth return sessionStats; } + function getSyncplayStats() { + var syncStats = []; + var stats = syncplayManager.getStats(); + + syncStats.push({ + label: globalize.translate("LabelSyncplayTimeDiff"), + value: stats.TimeDiff + "ms" + }); + + syncStats.push({ + label: globalize.translate("LabelSyncplayPlaybackDiff"), + value: stats.PlaybackDiff + "ms" + }); + + syncStats.push({ + label: globalize.translate("LabelSyncplaySyncMethod"), + value: stats.SyncMethod + }); + + return syncStats; + } + function getStats(instance, player) { var statsPromise = player.getStats ? player.getStats() : Promise.resolve({}); @@ -383,6 +405,13 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth name: 'Original Media Info' }); + if (syncplayManager.isSyncplayEnabled()) { + categories.push({ + stats: getSyncplayStats(), + name: 'Syncplay Info' + }); + } + return Promise.resolve(categories); }); } diff --git a/src/components/serverNotifications.js b/src/components/serverNotifications.js index e60f98475b..9776c88bd3 100644 --- a/src/components/serverNotifications.js +++ b/src/components/serverNotifications.js @@ -1,4 +1,4 @@ -define(['connectionManager', 'playbackManager', 'events', 'inputManager', 'focusManager', 'appRouter'], function (connectionManager, playbackManager, events, inputManager, focusManager, appRouter) { +define(['connectionManager', 'playbackManager', 'syncplayManager', 'events', 'inputManager', 'focusManager', 'appRouter'], function (connectionManager, playbackManager, syncplayManager, events, inputManager, focusManager, appRouter) { 'use strict'; var serverNotifications = {}; @@ -187,6 +187,10 @@ define(['connectionManager', 'playbackManager', 'events', 'inputManager', 'focus events.trigger(serverNotifications, 'UserDataChanged', [apiClient, msg.Data.UserDataList[i]]); } } + } else if (msg.MessageType === "SyncplayCommand") { + syncplayManager.processCommand(msg.Data, apiClient); + } else if (msg.MessageType === "SyncplayGroupUpdate") { + syncplayManager.processGroupUpdate(msg.Data, apiClient); } else { events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]); } diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js new file mode 100644 index 0000000000..01141741c1 --- /dev/null +++ b/src/components/syncplay/groupSelectionMenu.js @@ -0,0 +1,140 @@ +define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayManager', 'globalize', 'datetime'], function (events, loading, connectionManager, playbackManager, syncplayManager, globalize, datetime) { + 'use strict'; + + function getActivePlayerId() { + var info = playbackManager.getPlayerInfo(); + return info ? info.id : null; + } + + function emptyCallback() { + // avoid console logs about uncaught promises + } + + function showNewJoinGroupSelection(button) { + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + sessionId = sessionId ? sessionId : "none"; + + loading.show(); + + apiClient.sendSyncplayCommand(sessionId, "ListGroups").then(function (response) { + response.json().then(function (groups) { + var inSession = sessionId !== "none"; + + var menuItems = groups.map(function (group) { + var name = datetime.getDisplayRunningTime(group.PositionTicks); + if (!inSession) { + name = group.PlayingItemName + } + return { + name: name, + icon: "group", + id: group.GroupId, + selected: false, + secondaryText: group.Partecipants.join(", ") + } + }); + + if (inSession) { + menuItems.push({ + name: globalize.translate('LabelSyncplayNewGroup'), + icon: "add", + id: "new-group", + selected: true, + secondaryText: globalize.translate('LabelSyncplayNewGroupDescription') + }); + } + + if (menuItems.length === 0) { + require(['toast'], function (alert) { + alert({ + title: globalize.translate('MessageSyncplayNoGroupsAvailable'), + text: globalize.translate('MessageSyncplayNoGroupsAvailable') + }); + }); + loading.hide(); + return; + } + + require(['actionsheet'], function (actionsheet) { + + loading.hide(); + + var menuOptions = { + title: globalize.translate('HeaderSyncplaySelectGroup'), + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true + }; + + actionsheet.show(menuOptions).then(function (id) { + + if (id == "new-group") { + apiClient.sendSyncplayCommand(sessionId, "NewGroup"); + } else { + apiClient.sendSyncplayCommand(sessionId, "JoinGroup", { + GroupId: id + }); + } + }, emptyCallback); + }); + }); + }).catch(function (error) { + loading.hide(); + console.error(error); + }); + } + + function showLeaveGroupSelection(button) { + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + + loading.show(); + + var menuItems = [{ + name: globalize.translate('LabelSyncplayLeaveGroup'), + icon: "meeting_room", + id: "leave-group", + selected: true, + secondaryText: globalize.translate('LabelSyncplayLeaveGroupDescription') + }]; + + require(['actionsheet'], function (actionsheet) { + + loading.hide(); + + var menuOptions = { + title: globalize.translate('HeaderSyncplayEnabled'), + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true + }; + + actionsheet.show(menuOptions).then(function (id) { + if (id == "leave-group") { + apiClient.sendSyncplayCommand(sessionId, "LeaveGroup"); + } + }, emptyCallback); + }); + } + + function showGroupSelection(button) { + if (syncplayEnabled) { + showLeaveGroupSelection(button); + } else { + showNewJoinGroupSelection(button); + } + } + + var syncplayEnabled = false; + + events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) { + syncplayEnabled = enabled; + }); + + return { + show: showGroupSelection + }; +}); diff --git a/src/components/syncplay/syncplaymanager.js b/src/components/syncplay/syncplaymanager.js new file mode 100644 index 0000000000..bde47a9d44 --- /dev/null +++ b/src/components/syncplay/syncplaymanager.js @@ -0,0 +1,600 @@ +define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager'], function (events, globalize, loading, connectionManager, playbackManager) { + 'use strict'; + + function waitForEvent(emitter, eventType) { + return new Promise(function (resolve) { + var callback = function () { + events.off(emitter, eventType, callback); + resolve(arguments); + } + events.on(emitter, eventType, callback); + }); + } + + function displaySyncplayUpdate(message) { + require(['toast'], function (alert) { + alert({ + title: message.Header, + text: message.Text + }); + }); + } + + function getActivePlayerId() { + var info = playbackManager.getPlayerInfo(); + return info ? info.id : null; + } + + function millisecondsToTicks(milliseconds) { + return milliseconds * 10000; + } + + function ticksToMilliseconds(ticks) { + return ticks / 10000; + } + + function SyncplayManager() { + + var self = this; + + function onPlayerChange() { + bindToPlayer(playbackManager.getCurrentPlayer()); + events.trigger(self, "PlayerChange", [self.currentPlayer]); + } + + function onPlayPauseStateChanged(e) { + events.trigger(self, "PlayPauseStateChange", [self.currentPlayer]); + } + + self.playbackRateSupported = false; + self.syncEnabled = false; + self.maxAcceptedDelaySpeedToSync = 50; // milliseconds + self.maxAcceptedDelaySkipToSync = 300; // milliseconds + self.syncMethodThreshold = 2000; // milliseconds + self.speedUpToSyncTime = 1000; // milliseconds + self.playbackDiffMillis = 0; // used for stats + self.syncMethod = "None"; // used for stats + + function onTimeUpdate(e) { + events.trigger(self, "TimeUpdate", [e]); + + if (self.lastCommand && self.lastCommand.Command === 'Play' && !self.isBuffering()) { + var currentTime = new Date(); + var playAtTime = self.lastCommand.When; + + var state = playbackManager.getPlayerState().PlayState; + // Estimate PositionTicks on server + var ServerPositionTicks = self.lastCommand.PositionTicks + ((currentTime - playAtTime) - self.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; + + self.playbackDiffMillis = diffMillis; + + // console.debug("Syncplay onTimeUpdate", diffMillis, state.PositionTicks, ServerPositionTicks); + + if (self.syncEnabled) { + var absDiffMillis = Math.abs(diffMillis); + // TODO: SpeedToSync sounds bad on songs + if (self.playbackRateSupported && absDiffMillis > self.maxAcceptedDelaySpeedToSync && absDiffMillis < self.syncMethodThreshold) { + // SpeedToSync method + var speed = 1 + diffMillis / self.speedUpToSyncTime; + + self.currentPlayer.setPlaybackRate(speed); + self.syncEnabled = false; + self.showSyncIcon("SpeedToSync (x" + speed + ")"); + + self.syncTimeout = setTimeout(() => { + self.currentPlayer.setPlaybackRate(1); + self.syncEnabled = true; + self.clearSyncIcon(); + }, self.speedUpToSyncTime); + } else if (absDiffMillis > self.maxAcceptedDelaySkipToSync) { + // SkipToSync method + playbackManager.syncplay_seek(ServerPositionTicks); + self.syncEnabled = false; + self.showSyncIcon("SkipToSync"); + + self.syncTimeout = setTimeout(() => { + self.syncEnabled = true; + self.clearSyncIcon(); + }, self.syncMethodThreshold / 2); + } + } + } + } + + self.lastPlaybackWaiting = null; // used to determine if player's buffering + self.minBufferingThresholdMillis = 1000; + + // TODO: implement group wait + function onPlaying() { + self.lastPlaybackWaiting = null; + events.trigger(self, "PlayerPlaying"); + } + + // TODO: implement group wait + function onWaiting() { + if (!self.lastPlaybackWaiting) { + self.lastPlaybackWaiting = new Date(); + } + events.trigger(self, "PlayerWaiting"); + } + + self.isBuffering = function () { + if (self.lastPlaybackWaiting === null) return false; + return (new Date() - self.lastPlaybackWaiting) > self.minBufferingThresholdMillis; + } + + function bindToPlayer(player) { + if (player !== self.currentPlayer) { + releaseCurrentPlayer(); + self.currentPlayer = player; + if (!player) return; + } + events.on(player, "pause", onPlayPauseStateChanged); + events.on(player, "unpause", onPlayPauseStateChanged); + events.on(player, "timeupdate", onTimeUpdate); + events.on(player, "playing", onPlaying); + events.on(player, "waiting", onWaiting); + self.playbackRateSupported = player.supports("PlaybackRate"); + } + + function releaseCurrentPlayer() { + var player = self.currentPlayer; + if (player) { + events.off(player, "pause", onPlayPauseStateChanged); + events.off(player, "unpause", onPlayPauseStateChanged); + events.off(player, "timeupdate", onTimeUpdate); + events.off(player, "playing", onPlaying); + events.off(player, "waiting", onWaiting); + if (self.playbackRateSupported) { + player.setPlaybackRate(1); + } + self.currentPlayer = null; + self.playbackRateSupported = false; + } + } + + self.currentPlayer = null; + + events.on(playbackManager, "playerchange", onPlayerChange); + bindToPlayer(playbackManager.getCurrentPlayer()); + + self.syncplayEnabledAt = null; // Server time of when Syncplay has been enabled + self.syncplayReady = false; // Syncplay is ready after first ping to server + + self.processGroupUpdate = function (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(function () { + waitForEvent(self, "PlayerChange").then(function () { + playbackManager.pause(); + var sessionId = getActivePlayerId(); + if (!sessionId) { + console.error("Missing sessionId!"); + displaySyncplayUpdate({ + 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': + displaySyncplayUpdate({ + Text: globalize.translate('MessageSyncplayUserJoined', cmd.Data), + }); + break; + case 'UserLeft': + displaySyncplayUpdate({ + Text: globalize.translate('MessageSyncplayUserLeft', cmd.Data), + }); + break; + case 'GroupJoined': + displaySyncplayUpdate({ + Text: globalize.translate('MessageSyncplayEnabled'), + }); + // Enable Syncplay + self.syncplayEnabledAt = new Date(cmd.Data); + self.syncplayReady = false; + events.trigger(self, "SyncplayEnabled", [true]); + waitForEvent(self, "SyncplayReady").then(function () { + self.processCommand(self.queuedCommand, apiClient); + self.queuedCommand = null; + }); + self.injectPlaybackManager(); + self.startPing(); + break; + case 'NotInGroup': + case 'GroupLeft': + displaySyncplayUpdate({ + Text: globalize.translate('MessageSyncplayDisabled'), + }); + // Disable Syncplay + self.syncplayEnabledAt = null; + self.syncplayReady = false; + events.trigger(self, "SyncplayEnabled", [false]); + self.restorePlaybackManager(); + self.stopPing(); + break; + case 'GroupWait': + displaySyncplayUpdate({ + Text: globalize.translate('MessageSyncplayGroupWait', cmd.Data), + }); + break; + case 'KeepAlive': + break; + default: + console.error('processSyncplayGroupUpdate does not recognize: ' + cmd.Type); + break; + } + } + + self.lastCommand = null; + self.queuedCommand = null; + + self.processCommand = function (cmd, apiClient) { + if (cmd === null) return; + + if (!self.isSyncplayEnabled()) { + console.debug("Syncplay processCommand: ignoring command", cmd); + return; + } + + if (!self.syncplayReady) { + console.debug("Syncplay processCommand: queued command", cmd); + self.queuedCommand = cmd; + return; + } + + cmd.When = new Date(cmd.When); + + if (cmd.When < self.syncplayEnabledAt) { + console.debug("Syncplay processCommand: ignoring old command", cmd); + return; + } + + // Check if new command differs from last one + if (self.lastCommand && + self.lastCommand.When === cmd.When && + self.lastCommand.PositionTicks === cmd.PositionTicks && + self.Command === cmd.Command + ) { + console.debug("Syncplay processCommand: ignoring duplicate command", cmd); + return; + } + + self.lastCommand = cmd; + console.log("Syncplay will", cmd.Command, "at", cmd.When, "PositionTicks", cmd.PositionTicks); + + switch (cmd.Command) { + case 'Play': + self.schedulePlay(cmd.When, cmd.PositionTicks); + break; + case 'Pause': + self.schedulePause(cmd.When, cmd.PositionTicks); + break; + case 'Seek': + self.scheduleSeek(cmd.When, cmd.PositionTicks); + break; + default: + console.error('processSyncplayCommand does not recognize: ' + cmd.Type); + break; + } + } + + self.scheduledCommand = null; + self.syncTimeout = null; + + self.schedulePlay = function (playAtTime, positionTicks) { + self.clearScheduledCommand(); + var currentTime = new Date(); + var playAtTimeLocal = self.serverDateToLocal(playAtTime); + + if (playAtTimeLocal > currentTime) { + var playTimeout = (playAtTimeLocal - currentTime) - self.playerDelay; + playbackManager.syncplay_seek(positionTicks); + + self.scheduledCommand = setTimeout(() => { + playbackManager.syncplay_unpause(); + + self.syncTimeout = setTimeout(() => { + self.syncEnabled = true + }, self.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); + + self.syncTimeout = setTimeout(() => { + self.syncEnabled = true + }, self.syncMethodThreshold / 2); + } + } + + self.schedulePause = function (pauseAtTime, positionTicks) { + self.clearScheduledCommand(); + var currentTime = new Date(); + var pauseAtTimeLocal = self.serverDateToLocal(pauseAtTime); + + if (pauseAtTimeLocal > currentTime) { + var pauseTimeout = (pauseAtTimeLocal - currentTime) - self.playerDelay; + + self.scheduledCommand = setTimeout(() => { + playbackManager.syncplay_pause(); + setTimeout(() => { + playbackManager.syncplay_seek(positionTicks); + }, 800); + + }, pauseTimeout); + } else { + playbackManager.syncplay_pause(); + setTimeout(() => { + playbackManager.syncplay_seek(positionTicks); + }, 800); + } + } + + self.scheduleSeek = function (seekAtTime, positionTicks) { + self.schedulePause(seekAtTime, positionTicks); + } + + self.clearScheduledCommand = function () { + clearTimeout(self.scheduledCommand); + clearTimeout(self.syncTimeout); + + self.syncEnabled = false; + if (self.currentPlayer) { + self.currentPlayer.setPlaybackRate(1); + } + self.clearSyncIcon(); + } + + self.injectPlaybackManager = function () { + if (!self.isSyncplayEnabled()) return; + if (playbackManager.syncplayEnabled) return; + + playbackManager.syncplay_unpause = playbackManager.unpause; + playbackManager.syncplay_pause = playbackManager.pause; + playbackManager.syncplay_seek = playbackManager.seek; + + playbackManager.unpause = self.playRequest; + playbackManager.pause = self.pauseRequest; + playbackManager.seek = self.seekRequest; + playbackManager.syncplayEnabled = true; + } + + self.restorePlaybackManager = function () { + if (self.isSyncplayEnabled()) return; + if (!playbackManager.syncplayEnabled) return; + + playbackManager.unpause = playbackManager.syncplay_unpause; + playbackManager.pause = playbackManager.syncplay_pause; + playbackManager.seek = playbackManager.syncplay_seek; + playbackManager.syncplayEnabled = false; + } + + self.playRequest = function (player) { + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + apiClient.sendSyncplayCommand(sessionId, "PlayRequest"); + } + + self.pauseRequest = function (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(); + } + + self.seekRequest = function (PositionTicks, player) { + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + apiClient.sendSyncplayCommand(sessionId, "SeekRequest", { + PositionTicks: PositionTicks + }); + } + + self.pingIntervalTimeoutGreedy = 1000; + self.pingIntervalTimeoutLowProfile = 60000; + self.greedyPingCount = 3; + + self.pingStop = true; + self.pingIntervalTimeout = self.pingIntervalTimeoutGreedy; + self.pingInterval = null; + self.initTimeDiff = 0; // number of pings + self.timeDiff = 0; // local time minus server time + self.roundTripDuration = 0; + self.notifySyncplayReady = false; + + self.updateTimeDiff = function (pingStartTime, pingEndTime, serverTime) { + self.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) + (self.roundTripDuration / 2); + + // Initial setup + if (self.initTimeDiff === 0) { + self.timeDiff = newTimeDiff; + self.initTimeDiff++ + return; + } + + // As response time gets better, absolute value should decrease + var distanceFromZero = Math.abs(newTimeDiff); + var oldDistanceFromZero = Math.abs(self.timeDiff); + if (distanceFromZero < oldDistanceFromZero) { + self.timeDiff = newTimeDiff; + } + + // Avoid overloading server + if (self.initTimeDiff >= self.greedyPingCount) { + self.pingIntervalTimeout = self.pingIntervalTimeoutLowProfile; + } else { + self.initTimeDiff++; + } + + // console.debug("Syncplay updateTimeDiff:", serverTime, self.timeDiff, self.roundTripDuration, newTimeDiff); + } + + self.requestPing = function () { + if (self.pingInterval === null && !self.pingStop) { + self.pingInterval = setTimeout(() => { + self.pingInterval = null; + + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + + var pingStartTime = new Date(); + apiClient.sendSyncplayCommand(sessionId, "GetUtcTime").then(function (response) { + var pingEndTime = new Date(); + response.text().then(function (utcTime) { + var serverTime = new Date(utcTime); + self.updateTimeDiff(pingStartTime, pingEndTime, serverTime); + + // Alert user that ping is high + if (Math.abs(self.roundTripDuration) >= 1000) { + events.trigger(self, "SyncplayError", [true]); + } else { + events.trigger(self, "SyncplayError", [false]); + } + + // Notify server of ping + apiClient.sendSyncplayCommand(sessionId, "KeepAlive", { + Ping: (self.roundTripDuration / 2) + self.playerDelay + }); + + if (self.notifySyncplayReady) { + self.syncplayReady = true; + events.trigger(self, "SyncplayReady"); + self.notifySyncplayReady = false; + } + + self.requestPing(); + }); + }); + + }, self.pingIntervalTimeout); + } + } + + self.startPing = function () { + self.notifySyncplayReady = true; + self.pingStop = false; + self.initTimeDiff = self.initTimeDiff > self.greedyPingCount ? 1 : self.initTimeDiff; + self.pingIntervalTimeout = self.pingIntervalTimeoutGreedy; + + self.requestPing(); + } + + self.stopPing = function () { + self.pingStop = true; + if (self.pingInterval !== null) { + clearTimeout(self.pingInterval); + self.pingInterval = null; + } + } + + self.serverDateToLocal = function (server) { + // local - server = diff + return new Date(server.getTime() + self.timeDiff); + } + + self.localDateToServer = function (local) { + // local - server = diff + return new Date(local.getTime() - self.timeDiff); + } + + // THIS FEATURE IS CURRENTLY DISABLED + // Mainly because SpeedToSync seems to do the job + // Also because the delay is unreliable and different every time + self.playerDelay = 0; + self.playerDelayMeasured = true; // disable this feature + self.measurePlayerDelay = function (positionTicks) { + if (self.playerDelayMeasured) { + playbackManager.syncplay_seek(positionTicks); + } else { + // Measure playerDelay by issuing a play command + // followed by a pause command after one second + // PositionTicks should be at 1 second minus two times the player delay + loading.show(); + self.currentPlayer.setPlaybackRate(1); + playbackManager.syncplay_seek(0); + // Wait for player to seek + setTimeout(() => { + playbackManager.syncplay_unpause(); + // Play one second of media + setTimeout(() => { + playbackManager.syncplay_pause(); + // Wait for state to get update + setTimeout(() => { + var state = playbackManager.getPlayerState().PlayState; + var delayTicks = millisecondsToTicks(1000) - state.PositionTicks; + var delayMillis = ticksToMilliseconds(delayTicks); + self.playerDelay = delayMillis / 2; + // Make sure delay is not negative + self.playerDelay = self.playerDelay > 0 ? self.playerDelay : 0; + self.playerDelayMeasured = true; + // console.debug("Syncplay PlayerDelay:", self.playerDelay); + // Restore player + setTimeout(() => { + playbackManager.syncplay_seek(positionTicks); + loading.hide(); + }, 800); + }, 1000); + }, 1000); + }, 2000); + } + } + + // Stats + self.isSyncplayEnabled = function () { + return self.syncplayEnabledAt !== null ? true : false; + } + + self.getStats = function () { + return { + TimeDiff: self.timeDiff, + PlaybackDiff: self.playbackDiffMillis, + SyncMethod: self.syncMethod + } + } + + // UI + self.showSyncIcon = function (syncMethod) { + self.syncMethod = syncMethod; + events.trigger(self, "SyncplayError", [true]); + } + + self.clearSyncIcon = function () { + self.syncMethod = "None"; + events.trigger(self, "SyncplayError", [false]); + } + } + + return new SyncplayManager(); +}); diff --git a/src/scripts/librarymenu.js b/src/scripts/librarymenu.js index 81a381bff2..8afe5f10f1 100644 --- a/src/scripts/librarymenu.js +++ b/src/scripts/librarymenu.js @@ -1,4 +1,4 @@ -define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, browser, globalize, imageHelper) { +define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'syncplayManager', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, syncplayManager, browser, globalize, imageHelper) { 'use strict'; function renderHeader() { @@ -12,6 +12,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' html += ''; html += '
'; html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -30,6 +31,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' headerCastButton = skinHeader.querySelector('.headerCastButton'); headerAudioPlayerButton = skinHeader.querySelector('.headerAudioPlayerButton'); headerSearchButton = skinHeader.querySelector('.headerSearchButton'); + headerSyncButton = skinHeader.querySelector('.headerSyncButton'); lazyLoadViewMenuBarImages(); bindMenuEvents(); @@ -93,6 +95,8 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' } } + headerSyncButton.classList.remove("hide"); + requiresUserRefresh = false; } @@ -147,6 +151,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' } headerAudioPlayerButton.addEventListener('click', showAudioPlayer); + headerSyncButton.addEventListener('click', onSyncButtonClicked); if (layoutManager.mobile) { initHeadRoom(skinHeader); @@ -177,6 +182,32 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' }); } + function onSyncButtonClicked() { + var btn = this; + + require(["groupSelectionMenu"], function (groupSelectionMenu) { + groupSelectionMenu.show(btn); + }); + } + + function updateSyncplayIcon(event, enabled) { + var icon = headerSyncButton.querySelector("i"); + if (enabled) { + icon.innerHTML = "sync"; + } else { + icon.innerHTML = "sync_disabled"; + } + } + + function updateSyncplayErrorIcon(event, show_error) { + var icon = headerSyncButton.querySelector("i"); + if (show_error) { + icon.innerHTML = "sync_problem"; + } else { + icon.innerHTML = "sync"; + } + } + function getItemHref(item, context) { return appRouter.getRouteUrl(item, { context: context @@ -799,6 +830,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' var headerCastButton; var headerSearchButton; var headerAudioPlayerButton; + var headerSyncButton; var enableLibraryNavDrawer = layoutManager.desktop; var skinHeader = document.querySelector('.skinHeader'); var requiresUserRefresh = true; @@ -931,6 +963,8 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' updateUserInHeader(); }); events.on(playbackManager, 'playerchange', updateCastIcon); + events.on(syncplayManager, 'SyncplayEnabled', updateSyncplayIcon); + events.on(syncplayManager, 'SyncplayError', updateSyncplayErrorIcon); loadNavDrawer(); return LibraryMenu; }); diff --git a/src/scripts/site.js b/src/scripts/site.js index c00169d224..3a2ba13156 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -817,6 +817,7 @@ var AppInfo = {}; define('playbackSettings', [componentsPath + '/playbacksettings/playbacksettings'], returnFirstDependency); define('homescreenSettings', [componentsPath + '/homescreensettings/homescreensettings'], returnFirstDependency); define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager); + define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnFirstDependency); define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager); define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency); define('playMenu', [componentsPath + '/playmenu'], returnFirstDependency); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 9895bbc05c..c7611333f0 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -491,6 +491,8 @@ "HeaderSubtitleProfile": "Subtitle Profile", "HeaderSubtitleProfiles": "Subtitle Profiles", "HeaderSubtitleProfilesHelp": "Subtitle profiles describe the subtitle formats supported by the device.", + "HeaderSyncplaySelectGroup": "Join a group", + "HeaderSyncplayEnabled": "Syncplay enabled", "HeaderSystemDlnaProfiles": "System Profiles", "HeaderTags": "Tags", "HeaderTaskTriggers": "Task Triggers", @@ -853,6 +855,13 @@ "LabelSubtitlePlaybackMode": "Subtitle mode:", "LabelSubtitles": "Subtitles", "LabelSupportedMediaTypes": "Supported Media Types:", + "LabelSyncplayTimeDiff": "Time difference with server:", + "LabelSyncplayPlaybackDiff": "Playback time difference:", + "LabelSyncplaySyncMethod": "Sync method:", + "LabelSyncplayNewGroup": "New group", + "LabelSyncplayNewGroupDescription": "Create a new group", + "LabelSyncplayLeaveGroup": "Leave group", + "LabelSyncplayLeaveGroupDescription": "Disable Syncplay", "LabelTVHomeScreen": "TV mode home screen:", "LabelTag": "Tag:", "LabelTagline": "Tagline:", @@ -1016,6 +1025,12 @@ "MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.", "MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.", "MessageYouHaveVersionInstalled": "You currently have version {0} installed.", + "MessageSyncplayEnabled": "Syncplay enabled.", + "MessageSyncplayDisabled": "Syncplay disabled.", + "MessageSyncplayUserJoined": "{0} joined group.", + "MessageSyncplayUserLeft": "{0} left group.", + "MessageSyncplayGroupWait": "{0} is buffering...", + "MessageSyncplayNoGroupsAvailable": "No groups available.", "Metadata": "Metadata", "MetadataManager": "Metadata Manager", "MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.", From 460c2a1f77dbc805ba364b7f7e932e9d9d79486b Mon Sep 17 00:00:00 2001 From: gion Date: Wed, 1 Apr 2020 19:27:38 +0200 Subject: [PATCH 02/14] Fix lint errors --- src/components/playback/playbackmanager.js | 2 +- src/components/playerstats/playerstats.js | 2 +- src/components/syncplay/groupSelectionMenu.js | 4 +- src/components/syncplay/syncplaymanager.js | 76 +++++++++---------- 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index 923092cd3d..e4ce40cf4e 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -3789,7 +3789,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla player = player || this._currentPlayer; if (player) { - return player.getPlaybackRate(val); + return player.getPlaybackRate(); } return null; diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index ad4ce960bf..4bd49ba5c1 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -345,7 +345,7 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplay label: globalize.translate("LabelSyncplaySyncMethod"), value: stats.SyncMethod }); - + return syncStats; } diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js index 01141741c1..8fb0523c30 100644 --- a/src/components/syncplay/groupSelectionMenu.js +++ b/src/components/syncplay/groupSelectionMenu.js @@ -24,7 +24,7 @@ define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayMa var menuItems = groups.map(function (group) { var name = datetime.getDisplayRunningTime(group.PositionTicks); if (!inSession) { - name = group.PlayingItemName + name = group.PlayingItemName; } return { name: name, @@ -32,7 +32,7 @@ define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayMa id: group.GroupId, selected: false, secondaryText: group.Partecipants.join(", ") - } + }; }); if (inSession) { diff --git a/src/components/syncplay/syncplaymanager.js b/src/components/syncplay/syncplaymanager.js index bde47a9d44..995c140fd5 100644 --- a/src/components/syncplay/syncplaymanager.js +++ b/src/components/syncplay/syncplaymanager.js @@ -6,7 +6,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' var callback = function () { events.off(emitter, eventType, callback); resolve(arguments); - } + }; events.on(emitter, eventType, callback); }); } @@ -80,11 +80,11 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' if (self.playbackRateSupported && absDiffMillis > self.maxAcceptedDelaySpeedToSync && absDiffMillis < self.syncMethodThreshold) { // SpeedToSync method var speed = 1 + diffMillis / self.speedUpToSyncTime; - + self.currentPlayer.setPlaybackRate(speed); self.syncEnabled = false; self.showSyncIcon("SpeedToSync (x" + speed + ")"); - + self.syncTimeout = setTimeout(() => { self.currentPlayer.setPlaybackRate(1); self.syncEnabled = true; @@ -95,7 +95,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' playbackManager.syncplay_seek(ServerPositionTicks); self.syncEnabled = false; self.showSyncIcon("SkipToSync"); - + self.syncTimeout = setTimeout(() => { self.syncEnabled = true; self.clearSyncIcon(); @@ -105,7 +105,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' } } - self.lastPlaybackWaiting = null; // used to determine if player's buffering + self.lastPlaybackWaiting = null; // used to determine if player's buffering self.minBufferingThresholdMillis = 1000; // TODO: implement group wait @@ -119,13 +119,13 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' if (!self.lastPlaybackWaiting) { self.lastPlaybackWaiting = new Date(); } - events.trigger(self, "PlayerWaiting"); + events.trigger(self, "PlayerWaiting"); } self.isBuffering = function () { if (self.lastPlaybackWaiting === null) return false; return (new Date() - self.lastPlaybackWaiting) > self.minBufferingThresholdMillis; - } + }; function bindToPlayer(player) { if (player !== self.currentPlayer) { @@ -165,7 +165,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' self.syncplayEnabledAt = null; // Server time of when Syncplay has been enabled self.syncplayReady = false; // Syncplay is ready after first ping to server - self.processGroupUpdate = function (cmd, apiClient) { + self.processGroupUpdate = function (cmd, apiClient) { switch (cmd.Type) { case 'PrepareSession': var serverId = apiClient.serverInfo().Id; @@ -199,17 +199,17 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' break; case 'UserJoined': displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayUserJoined', cmd.Data), + Text: globalize.translate('MessageSyncplayUserJoined', cmd.Data) }); break; case 'UserLeft': displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayUserLeft', cmd.Data), + Text: globalize.translate('MessageSyncplayUserLeft', cmd.Data) }); break; case 'GroupJoined': displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayEnabled'), + Text: globalize.translate('MessageSyncplayEnabled') }); // Enable Syncplay self.syncplayEnabledAt = new Date(cmd.Data); @@ -225,7 +225,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' case 'NotInGroup': case 'GroupLeft': displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayDisabled'), + Text: globalize.translate('MessageSyncplayDisabled') }); // Disable Syncplay self.syncplayEnabledAt = null; @@ -236,7 +236,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' break; case 'GroupWait': displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayGroupWait', cmd.Data), + Text: globalize.translate('MessageSyncplayGroupWait', cmd.Data) }); break; case 'KeepAlive': @@ -245,7 +245,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' console.error('processSyncplayGroupUpdate does not recognize: ' + cmd.Type); break; } - } + }; self.lastCommand = null; self.queuedCommand = null; @@ -283,7 +283,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' self.lastCommand = cmd; console.log("Syncplay will", cmd.Command, "at", cmd.When, "PositionTicks", cmd.PositionTicks); - + switch (cmd.Command) { case 'Play': self.schedulePlay(cmd.When, cmd.PositionTicks); @@ -298,7 +298,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' console.error('processSyncplayCommand does not recognize: ' + cmd.Type); break; } - } + }; self.scheduledCommand = null; self.syncTimeout = null; @@ -332,7 +332,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' self.syncEnabled = true }, self.syncMethodThreshold / 2); } - } + }; self.schedulePause = function (pauseAtTime, positionTicks) { self.clearScheduledCommand(); @@ -355,11 +355,11 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' playbackManager.syncplay_seek(positionTicks); }, 800); } - } + }; self.scheduleSeek = function (seekAtTime, positionTicks) { self.schedulePause(seekAtTime, positionTicks); - } + }; self.clearScheduledCommand = function () { clearTimeout(self.scheduledCommand); @@ -370,7 +370,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' self.currentPlayer.setPlaybackRate(1); } self.clearSyncIcon(); - } + }; self.injectPlaybackManager = function () { if (!self.isSyncplayEnabled()) return; @@ -379,12 +379,12 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' playbackManager.syncplay_unpause = playbackManager.unpause; playbackManager.syncplay_pause = playbackManager.pause; playbackManager.syncplay_seek = playbackManager.seek; - + playbackManager.unpause = self.playRequest; playbackManager.pause = self.pauseRequest; playbackManager.seek = self.seekRequest; playbackManager.syncplayEnabled = true; - } + }; self.restorePlaybackManager = function () { if (self.isSyncplayEnabled()) return; @@ -394,13 +394,13 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' playbackManager.pause = playbackManager.syncplay_pause; playbackManager.seek = playbackManager.syncplay_seek; playbackManager.syncplayEnabled = false; - } + }; self.playRequest = function (player) { var apiClient = connectionManager.currentApiClient(); var sessionId = getActivePlayerId(); apiClient.sendSyncplayCommand(sessionId, "PlayRequest"); - } + }; self.pauseRequest = function (player) { var apiClient = connectionManager.currentApiClient(); @@ -408,7 +408,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' apiClient.sendSyncplayCommand(sessionId, "PauseRequest"); // Pause locally as well, to give the user some little control playbackManager.syncplay_pause(); - } + }; self.seekRequest = function (PositionTicks, player) { var apiClient = connectionManager.currentApiClient(); @@ -416,7 +416,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' apiClient.sendSyncplayCommand(sessionId, "SeekRequest", { PositionTicks: PositionTicks }); - } + }; self.pingIntervalTimeoutGreedy = 1000; self.pingIntervalTimeoutLowProfile = 60000; @@ -459,7 +459,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' } // console.debug("Syncplay updateTimeDiff:", serverTime, self.timeDiff, self.roundTripDuration, newTimeDiff); - } + }; self.requestPing = function () { if (self.pingInterval === null && !self.pingStop) { @@ -493,14 +493,14 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' events.trigger(self, "SyncplayReady"); self.notifySyncplayReady = false; } - + self.requestPing(); }); }); }, self.pingIntervalTimeout); } - } + }; self.startPing = function () { self.notifySyncplayReady = true; @@ -509,7 +509,7 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' self.pingIntervalTimeout = self.pingIntervalTimeoutGreedy; self.requestPing(); - } + }; self.stopPing = function () { self.pingStop = true; @@ -517,17 +517,17 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' clearTimeout(self.pingInterval); self.pingInterval = null; } - } + }; self.serverDateToLocal = function (server) { // local - server = diff return new Date(server.getTime() + self.timeDiff); - } + }; self.localDateToServer = function (local) { // local - server = diff return new Date(local.getTime() - self.timeDiff); - } + }; // THIS FEATURE IS CURRENTLY DISABLED // Mainly because SpeedToSync seems to do the job @@ -569,12 +569,12 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' }, 1000); }, 2000); } - } + }; // Stats self.isSyncplayEnabled = function () { return self.syncplayEnabledAt !== null ? true : false; - } + }; self.getStats = function () { return { @@ -582,18 +582,18 @@ define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager' PlaybackDiff: self.playbackDiffMillis, SyncMethod: self.syncMethod } - } + }; // UI self.showSyncIcon = function (syncMethod) { self.syncMethod = syncMethod; events.trigger(self, "SyncplayError", [true]); - } + }; self.clearSyncIcon = function () { self.syncMethod = "None"; events.trigger(self, "SyncplayError", [false]); - } + }; } return new SyncplayManager(); From 56ee678fc222bd50ea9e540fa8ffe0698dd67e7c Mon Sep 17 00:00:00 2001 From: gion Date: Fri, 3 Apr 2020 18:49:19 +0200 Subject: [PATCH 03/14] Port to ES6 --- package.json | 4 +- src/components/syncplay/groupSelectionMenu.js | 239 +++--- src/components/syncplay/syncplayManager.js | 704 ++++++++++++++++++ src/components/syncplay/syncplaymanager.js | 600 --------------- src/scripts/site.js | 9 +- 5 files changed, 840 insertions(+), 716 deletions(-) create mode 100644 src/components/syncplay/syncplayManager.js delete mode 100644 src/components/syncplay/syncplaymanager.js diff --git a/package.json b/package.json index ffbbc02954..39330cf600 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,9 @@ "src/scripts/keyboardnavigation.js", "src/scripts/settings/appSettings.js", "src/scripts/settings/userSettings.js", - "src/scripts/settings/webSettings.js" + "src/scripts/settings/webSettings.js", + "src/components/syncplay/groupSelectionMenu.js", + "src/components/syncplay/syncplayManager.js" ], "plugins": [ "@babel/plugin-transform-modules-amd" diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js index 8fb0523c30..671b76d5ea 100644 --- a/src/components/syncplay/groupSelectionMenu.js +++ b/src/components/syncplay/groupSelectionMenu.js @@ -1,111 +1,78 @@ -define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayManager', 'globalize', 'datetime'], function (events, loading, connectionManager, playbackManager, syncplayManager, globalize, datetime) { - 'use strict'; +import events from 'events'; +import connectionManager from 'connectionManager'; +import playbackManager from 'playbackManager'; +import syncplayManager from 'syncplayManager'; +import loading from 'loading'; +import datetime from 'datetime'; +import toast from 'toast'; +import actionsheet from 'actionsheet'; +import globalize from 'globalize'; - function getActivePlayerId() { - var info = playbackManager.getPlayerInfo(); - return info ? info.id : null; - } +/** + * Gets active player id. + * @returns {string} The player's id. + */ +function getActivePlayerId() { + var info = playbackManager.getPlayerInfo(); + return info ? info.id : null; +} - function emptyCallback() { - // avoid console logs about uncaught promises - } +/** + * Used to avoid console logs about uncaught promises + */ +function emptyCallback() { + // avoid console logs about uncaught promises +} - function showNewJoinGroupSelection(button) { - var apiClient = connectionManager.currentApiClient(); - var sessionId = getActivePlayerId(); - sessionId = sessionId ? sessionId : "none"; +/** + * Used when user needs to join a group. + * @param {HTMLElement} button - Element where to place the menu. + */ +function showNewJoinGroupSelection(button) { + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + sessionId = sessionId ? sessionId : "none"; - loading.show(); + loading.show(); - apiClient.sendSyncplayCommand(sessionId, "ListGroups").then(function (response) { - response.json().then(function (groups) { - var inSession = sessionId !== "none"; + apiClient.sendSyncplayCommand(sessionId, "ListGroups").then(function (response) { + response.json().then(function (groups) { + var inSession = sessionId !== "none"; - var menuItems = groups.map(function (group) { - var name = datetime.getDisplayRunningTime(group.PositionTicks); - if (!inSession) { - name = group.PlayingItemName; - } - return { - name: name, - icon: "group", - id: group.GroupId, - selected: false, - secondaryText: group.Partecipants.join(", ") - }; - }); - - if (inSession) { - menuItems.push({ - name: globalize.translate('LabelSyncplayNewGroup'), - icon: "add", - id: "new-group", - selected: true, - secondaryText: globalize.translate('LabelSyncplayNewGroupDescription') - }); + var menuItems = groups.map(function (group) { + var name = datetime.getDisplayRunningTime(group.PositionTicks); + if (!inSession) { + name = group.PlayingItemName; } - - if (menuItems.length === 0) { - require(['toast'], function (alert) { - alert({ - title: globalize.translate('MessageSyncplayNoGroupsAvailable'), - text: globalize.translate('MessageSyncplayNoGroupsAvailable') - }); - }); - loading.hide(); - return; - } - - require(['actionsheet'], function (actionsheet) { - - loading.hide(); - - var menuOptions = { - title: globalize.translate('HeaderSyncplaySelectGroup'), - items: menuItems, - positionTo: button, - resolveOnClick: true, - border: true - }; - - actionsheet.show(menuOptions).then(function (id) { - - if (id == "new-group") { - apiClient.sendSyncplayCommand(sessionId, "NewGroup"); - } else { - apiClient.sendSyncplayCommand(sessionId, "JoinGroup", { - GroupId: id - }); - } - }, emptyCallback); - }); + return { + name: name, + icon: "group", + id: group.GroupId, + selected: false, + secondaryText: group.Partecipants.join(", ") + }; }); - }).catch(function (error) { - loading.hide(); - console.error(error); - }); - } - function showLeaveGroupSelection(button) { - var apiClient = connectionManager.currentApiClient(); - var sessionId = getActivePlayerId(); + if (inSession) { + menuItems.push({ + name: globalize.translate('LabelSyncplayNewGroup'), + icon: "add", + id: "new-group", + selected: true, + secondaryText: globalize.translate('LabelSyncplayNewGroupDescription') + }); + } - loading.show(); - - var menuItems = [{ - name: globalize.translate('LabelSyncplayLeaveGroup'), - icon: "meeting_room", - id: "leave-group", - selected: true, - secondaryText: globalize.translate('LabelSyncplayLeaveGroupDescription') - }]; - - require(['actionsheet'], function (actionsheet) { - - loading.hide(); + if (menuItems.length === 0) { + toast({ + text: globalize.translate('MessageSyncplayNoGroupsAvailable') + }); + loading.hide(); + return; + } var menuOptions = { - title: globalize.translate('HeaderSyncplayEnabled'), + title: globalize.translate('HeaderSyncplaySelectGroup'), items: menuItems, positionTo: button, resolveOnClick: true, @@ -113,28 +80,72 @@ define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayMa }; actionsheet.show(menuOptions).then(function (id) { - if (id == "leave-group") { - apiClient.sendSyncplayCommand(sessionId, "LeaveGroup"); + if (id == "new-group") { + apiClient.sendSyncplayCommand(sessionId, "NewGroup"); + } else { + apiClient.sendSyncplayCommand(sessionId, "JoinGroup", { + GroupId: id + }); } }, emptyCallback); + + loading.hide(); }); - } - - function showGroupSelection(button) { - if (syncplayEnabled) { - showLeaveGroupSelection(button); - } else { - showNewJoinGroupSelection(button); - } - } - - var syncplayEnabled = false; - - events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) { - syncplayEnabled = enabled; + }).catch(function (error) { + loading.hide(); + console.error(error); }); +} - return { - show: showGroupSelection +/** + * Used when user has joined a group. + * @param {HTMLElement} button - Element where to place the menu. + */ +function showLeaveGroupSelection(button) { + const apiClient = connectionManager.currentApiClient(); + const sessionId = getActivePlayerId(); + + loading.show(); + + const menuItems = [{ + name: globalize.translate('LabelSyncplayLeaveGroup'), + icon: "meeting_room", + id: "leave-group", + selected: true, + secondaryText: globalize.translate('LabelSyncplayLeaveGroupDescription') + }]; + + var menuOptions = { + title: globalize.translate('HeaderSyncplayEnabled'), + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true }; + + actionsheet.show(menuOptions).then(function (id) { + if (id == "leave-group") { + apiClient.sendSyncplayCommand(sessionId, "LeaveGroup"); + } + }, emptyCallback); + + loading.hide(); +} + +// Register to Syncplay events +let syncplayEnabled = false; +events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) { + syncplayEnabled = enabled; }); + +/** + * Shows a menu to handle Syncplay groups. + * @param {HTMLElement} button - Element where to place the menu. + */ +export function show(button) { + if (syncplayEnabled) { + showLeaveGroupSelection(button); + } else { + showNewJoinGroupSelection(button); + } +} diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncplayManager.js new file mode 100644 index 0000000000..bc0fe7bc6c --- /dev/null +++ b/src/components/syncplay/syncplayManager.js @@ -0,0 +1,704 @@ +/* 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(); diff --git a/src/components/syncplay/syncplaymanager.js b/src/components/syncplay/syncplaymanager.js deleted file mode 100644 index 995c140fd5..0000000000 --- a/src/components/syncplay/syncplaymanager.js +++ /dev/null @@ -1,600 +0,0 @@ -define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager'], function (events, globalize, loading, connectionManager, playbackManager) { - 'use strict'; - - function waitForEvent(emitter, eventType) { - return new Promise(function (resolve) { - var callback = function () { - events.off(emitter, eventType, callback); - resolve(arguments); - }; - events.on(emitter, eventType, callback); - }); - } - - function displaySyncplayUpdate(message) { - require(['toast'], function (alert) { - alert({ - title: message.Header, - text: message.Text - }); - }); - } - - function getActivePlayerId() { - var info = playbackManager.getPlayerInfo(); - return info ? info.id : null; - } - - function millisecondsToTicks(milliseconds) { - return milliseconds * 10000; - } - - function ticksToMilliseconds(ticks) { - return ticks / 10000; - } - - function SyncplayManager() { - - var self = this; - - function onPlayerChange() { - bindToPlayer(playbackManager.getCurrentPlayer()); - events.trigger(self, "PlayerChange", [self.currentPlayer]); - } - - function onPlayPauseStateChanged(e) { - events.trigger(self, "PlayPauseStateChange", [self.currentPlayer]); - } - - self.playbackRateSupported = false; - self.syncEnabled = false; - self.maxAcceptedDelaySpeedToSync = 50; // milliseconds - self.maxAcceptedDelaySkipToSync = 300; // milliseconds - self.syncMethodThreshold = 2000; // milliseconds - self.speedUpToSyncTime = 1000; // milliseconds - self.playbackDiffMillis = 0; // used for stats - self.syncMethod = "None"; // used for stats - - function onTimeUpdate(e) { - events.trigger(self, "TimeUpdate", [e]); - - if (self.lastCommand && self.lastCommand.Command === 'Play' && !self.isBuffering()) { - var currentTime = new Date(); - var playAtTime = self.lastCommand.When; - - var state = playbackManager.getPlayerState().PlayState; - // Estimate PositionTicks on server - var ServerPositionTicks = self.lastCommand.PositionTicks + ((currentTime - playAtTime) - self.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; - - self.playbackDiffMillis = diffMillis; - - // console.debug("Syncplay onTimeUpdate", diffMillis, state.PositionTicks, ServerPositionTicks); - - if (self.syncEnabled) { - var absDiffMillis = Math.abs(diffMillis); - // TODO: SpeedToSync sounds bad on songs - if (self.playbackRateSupported && absDiffMillis > self.maxAcceptedDelaySpeedToSync && absDiffMillis < self.syncMethodThreshold) { - // SpeedToSync method - var speed = 1 + diffMillis / self.speedUpToSyncTime; - - self.currentPlayer.setPlaybackRate(speed); - self.syncEnabled = false; - self.showSyncIcon("SpeedToSync (x" + speed + ")"); - - self.syncTimeout = setTimeout(() => { - self.currentPlayer.setPlaybackRate(1); - self.syncEnabled = true; - self.clearSyncIcon(); - }, self.speedUpToSyncTime); - } else if (absDiffMillis > self.maxAcceptedDelaySkipToSync) { - // SkipToSync method - playbackManager.syncplay_seek(ServerPositionTicks); - self.syncEnabled = false; - self.showSyncIcon("SkipToSync"); - - self.syncTimeout = setTimeout(() => { - self.syncEnabled = true; - self.clearSyncIcon(); - }, self.syncMethodThreshold / 2); - } - } - } - } - - self.lastPlaybackWaiting = null; // used to determine if player's buffering - self.minBufferingThresholdMillis = 1000; - - // TODO: implement group wait - function onPlaying() { - self.lastPlaybackWaiting = null; - events.trigger(self, "PlayerPlaying"); - } - - // TODO: implement group wait - function onWaiting() { - if (!self.lastPlaybackWaiting) { - self.lastPlaybackWaiting = new Date(); - } - events.trigger(self, "PlayerWaiting"); - } - - self.isBuffering = function () { - if (self.lastPlaybackWaiting === null) return false; - return (new Date() - self.lastPlaybackWaiting) > self.minBufferingThresholdMillis; - }; - - function bindToPlayer(player) { - if (player !== self.currentPlayer) { - releaseCurrentPlayer(); - self.currentPlayer = player; - if (!player) return; - } - events.on(player, "pause", onPlayPauseStateChanged); - events.on(player, "unpause", onPlayPauseStateChanged); - events.on(player, "timeupdate", onTimeUpdate); - events.on(player, "playing", onPlaying); - events.on(player, "waiting", onWaiting); - self.playbackRateSupported = player.supports("PlaybackRate"); - } - - function releaseCurrentPlayer() { - var player = self.currentPlayer; - if (player) { - events.off(player, "pause", onPlayPauseStateChanged); - events.off(player, "unpause", onPlayPauseStateChanged); - events.off(player, "timeupdate", onTimeUpdate); - events.off(player, "playing", onPlaying); - events.off(player, "waiting", onWaiting); - if (self.playbackRateSupported) { - player.setPlaybackRate(1); - } - self.currentPlayer = null; - self.playbackRateSupported = false; - } - } - - self.currentPlayer = null; - - events.on(playbackManager, "playerchange", onPlayerChange); - bindToPlayer(playbackManager.getCurrentPlayer()); - - self.syncplayEnabledAt = null; // Server time of when Syncplay has been enabled - self.syncplayReady = false; // Syncplay is ready after first ping to server - - self.processGroupUpdate = function (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(function () { - waitForEvent(self, "PlayerChange").then(function () { - playbackManager.pause(); - var sessionId = getActivePlayerId(); - if (!sessionId) { - console.error("Missing sessionId!"); - displaySyncplayUpdate({ - 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': - displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayUserJoined', cmd.Data) - }); - break; - case 'UserLeft': - displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayUserLeft', cmd.Data) - }); - break; - case 'GroupJoined': - displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayEnabled') - }); - // Enable Syncplay - self.syncplayEnabledAt = new Date(cmd.Data); - self.syncplayReady = false; - events.trigger(self, "SyncplayEnabled", [true]); - waitForEvent(self, "SyncplayReady").then(function () { - self.processCommand(self.queuedCommand, apiClient); - self.queuedCommand = null; - }); - self.injectPlaybackManager(); - self.startPing(); - break; - case 'NotInGroup': - case 'GroupLeft': - displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayDisabled') - }); - // Disable Syncplay - self.syncplayEnabledAt = null; - self.syncplayReady = false; - events.trigger(self, "SyncplayEnabled", [false]); - self.restorePlaybackManager(); - self.stopPing(); - break; - case 'GroupWait': - displaySyncplayUpdate({ - Text: globalize.translate('MessageSyncplayGroupWait', cmd.Data) - }); - break; - case 'KeepAlive': - break; - default: - console.error('processSyncplayGroupUpdate does not recognize: ' + cmd.Type); - break; - } - }; - - self.lastCommand = null; - self.queuedCommand = null; - - self.processCommand = function (cmd, apiClient) { - if (cmd === null) return; - - if (!self.isSyncplayEnabled()) { - console.debug("Syncplay processCommand: ignoring command", cmd); - return; - } - - if (!self.syncplayReady) { - console.debug("Syncplay processCommand: queued command", cmd); - self.queuedCommand = cmd; - return; - } - - cmd.When = new Date(cmd.When); - - if (cmd.When < self.syncplayEnabledAt) { - console.debug("Syncplay processCommand: ignoring old command", cmd); - return; - } - - // Check if new command differs from last one - if (self.lastCommand && - self.lastCommand.When === cmd.When && - self.lastCommand.PositionTicks === cmd.PositionTicks && - self.Command === cmd.Command - ) { - console.debug("Syncplay processCommand: ignoring duplicate command", cmd); - return; - } - - self.lastCommand = cmd; - console.log("Syncplay will", cmd.Command, "at", cmd.When, "PositionTicks", cmd.PositionTicks); - - switch (cmd.Command) { - case 'Play': - self.schedulePlay(cmd.When, cmd.PositionTicks); - break; - case 'Pause': - self.schedulePause(cmd.When, cmd.PositionTicks); - break; - case 'Seek': - self.scheduleSeek(cmd.When, cmd.PositionTicks); - break; - default: - console.error('processSyncplayCommand does not recognize: ' + cmd.Type); - break; - } - }; - - self.scheduledCommand = null; - self.syncTimeout = null; - - self.schedulePlay = function (playAtTime, positionTicks) { - self.clearScheduledCommand(); - var currentTime = new Date(); - var playAtTimeLocal = self.serverDateToLocal(playAtTime); - - if (playAtTimeLocal > currentTime) { - var playTimeout = (playAtTimeLocal - currentTime) - self.playerDelay; - playbackManager.syncplay_seek(positionTicks); - - self.scheduledCommand = setTimeout(() => { - playbackManager.syncplay_unpause(); - - self.syncTimeout = setTimeout(() => { - self.syncEnabled = true - }, self.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); - - self.syncTimeout = setTimeout(() => { - self.syncEnabled = true - }, self.syncMethodThreshold / 2); - } - }; - - self.schedulePause = function (pauseAtTime, positionTicks) { - self.clearScheduledCommand(); - var currentTime = new Date(); - var pauseAtTimeLocal = self.serverDateToLocal(pauseAtTime); - - if (pauseAtTimeLocal > currentTime) { - var pauseTimeout = (pauseAtTimeLocal - currentTime) - self.playerDelay; - - self.scheduledCommand = setTimeout(() => { - playbackManager.syncplay_pause(); - setTimeout(() => { - playbackManager.syncplay_seek(positionTicks); - }, 800); - - }, pauseTimeout); - } else { - playbackManager.syncplay_pause(); - setTimeout(() => { - playbackManager.syncplay_seek(positionTicks); - }, 800); - } - }; - - self.scheduleSeek = function (seekAtTime, positionTicks) { - self.schedulePause(seekAtTime, positionTicks); - }; - - self.clearScheduledCommand = function () { - clearTimeout(self.scheduledCommand); - clearTimeout(self.syncTimeout); - - self.syncEnabled = false; - if (self.currentPlayer) { - self.currentPlayer.setPlaybackRate(1); - } - self.clearSyncIcon(); - }; - - self.injectPlaybackManager = function () { - if (!self.isSyncplayEnabled()) return; - if (playbackManager.syncplayEnabled) return; - - playbackManager.syncplay_unpause = playbackManager.unpause; - playbackManager.syncplay_pause = playbackManager.pause; - playbackManager.syncplay_seek = playbackManager.seek; - - playbackManager.unpause = self.playRequest; - playbackManager.pause = self.pauseRequest; - playbackManager.seek = self.seekRequest; - playbackManager.syncplayEnabled = true; - }; - - self.restorePlaybackManager = function () { - if (self.isSyncplayEnabled()) return; - if (!playbackManager.syncplayEnabled) return; - - playbackManager.unpause = playbackManager.syncplay_unpause; - playbackManager.pause = playbackManager.syncplay_pause; - playbackManager.seek = playbackManager.syncplay_seek; - playbackManager.syncplayEnabled = false; - }; - - self.playRequest = function (player) { - var apiClient = connectionManager.currentApiClient(); - var sessionId = getActivePlayerId(); - apiClient.sendSyncplayCommand(sessionId, "PlayRequest"); - }; - - self.pauseRequest = function (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(); - }; - - self.seekRequest = function (PositionTicks, player) { - var apiClient = connectionManager.currentApiClient(); - var sessionId = getActivePlayerId(); - apiClient.sendSyncplayCommand(sessionId, "SeekRequest", { - PositionTicks: PositionTicks - }); - }; - - self.pingIntervalTimeoutGreedy = 1000; - self.pingIntervalTimeoutLowProfile = 60000; - self.greedyPingCount = 3; - - self.pingStop = true; - self.pingIntervalTimeout = self.pingIntervalTimeoutGreedy; - self.pingInterval = null; - self.initTimeDiff = 0; // number of pings - self.timeDiff = 0; // local time minus server time - self.roundTripDuration = 0; - self.notifySyncplayReady = false; - - self.updateTimeDiff = function (pingStartTime, pingEndTime, serverTime) { - self.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) + (self.roundTripDuration / 2); - - // Initial setup - if (self.initTimeDiff === 0) { - self.timeDiff = newTimeDiff; - self.initTimeDiff++ - return; - } - - // As response time gets better, absolute value should decrease - var distanceFromZero = Math.abs(newTimeDiff); - var oldDistanceFromZero = Math.abs(self.timeDiff); - if (distanceFromZero < oldDistanceFromZero) { - self.timeDiff = newTimeDiff; - } - - // Avoid overloading server - if (self.initTimeDiff >= self.greedyPingCount) { - self.pingIntervalTimeout = self.pingIntervalTimeoutLowProfile; - } else { - self.initTimeDiff++; - } - - // console.debug("Syncplay updateTimeDiff:", serverTime, self.timeDiff, self.roundTripDuration, newTimeDiff); - }; - - self.requestPing = function () { - if (self.pingInterval === null && !self.pingStop) { - self.pingInterval = setTimeout(() => { - self.pingInterval = null; - - var apiClient = connectionManager.currentApiClient(); - var sessionId = getActivePlayerId(); - - var pingStartTime = new Date(); - apiClient.sendSyncplayCommand(sessionId, "GetUtcTime").then(function (response) { - var pingEndTime = new Date(); - response.text().then(function (utcTime) { - var serverTime = new Date(utcTime); - self.updateTimeDiff(pingStartTime, pingEndTime, serverTime); - - // Alert user that ping is high - if (Math.abs(self.roundTripDuration) >= 1000) { - events.trigger(self, "SyncplayError", [true]); - } else { - events.trigger(self, "SyncplayError", [false]); - } - - // Notify server of ping - apiClient.sendSyncplayCommand(sessionId, "KeepAlive", { - Ping: (self.roundTripDuration / 2) + self.playerDelay - }); - - if (self.notifySyncplayReady) { - self.syncplayReady = true; - events.trigger(self, "SyncplayReady"); - self.notifySyncplayReady = false; - } - - self.requestPing(); - }); - }); - - }, self.pingIntervalTimeout); - } - }; - - self.startPing = function () { - self.notifySyncplayReady = true; - self.pingStop = false; - self.initTimeDiff = self.initTimeDiff > self.greedyPingCount ? 1 : self.initTimeDiff; - self.pingIntervalTimeout = self.pingIntervalTimeoutGreedy; - - self.requestPing(); - }; - - self.stopPing = function () { - self.pingStop = true; - if (self.pingInterval !== null) { - clearTimeout(self.pingInterval); - self.pingInterval = null; - } - }; - - self.serverDateToLocal = function (server) { - // local - server = diff - return new Date(server.getTime() + self.timeDiff); - }; - - self.localDateToServer = function (local) { - // local - server = diff - return new Date(local.getTime() - self.timeDiff); - }; - - // THIS FEATURE IS CURRENTLY DISABLED - // Mainly because SpeedToSync seems to do the job - // Also because the delay is unreliable and different every time - self.playerDelay = 0; - self.playerDelayMeasured = true; // disable this feature - self.measurePlayerDelay = function (positionTicks) { - if (self.playerDelayMeasured) { - playbackManager.syncplay_seek(positionTicks); - } else { - // Measure playerDelay by issuing a play command - // followed by a pause command after one second - // PositionTicks should be at 1 second minus two times the player delay - loading.show(); - self.currentPlayer.setPlaybackRate(1); - playbackManager.syncplay_seek(0); - // Wait for player to seek - setTimeout(() => { - playbackManager.syncplay_unpause(); - // Play one second of media - setTimeout(() => { - playbackManager.syncplay_pause(); - // Wait for state to get update - setTimeout(() => { - var state = playbackManager.getPlayerState().PlayState; - var delayTicks = millisecondsToTicks(1000) - state.PositionTicks; - var delayMillis = ticksToMilliseconds(delayTicks); - self.playerDelay = delayMillis / 2; - // Make sure delay is not negative - self.playerDelay = self.playerDelay > 0 ? self.playerDelay : 0; - self.playerDelayMeasured = true; - // console.debug("Syncplay PlayerDelay:", self.playerDelay); - // Restore player - setTimeout(() => { - playbackManager.syncplay_seek(positionTicks); - loading.hide(); - }, 800); - }, 1000); - }, 1000); - }, 2000); - } - }; - - // Stats - self.isSyncplayEnabled = function () { - return self.syncplayEnabledAt !== null ? true : false; - }; - - self.getStats = function () { - return { - TimeDiff: self.timeDiff, - PlaybackDiff: self.playbackDiffMillis, - SyncMethod: self.syncMethod - } - }; - - // UI - self.showSyncIcon = function (syncMethod) { - self.syncMethod = syncMethod; - events.trigger(self, "SyncplayError", [true]); - }; - - self.clearSyncIcon = function () { - self.syncMethod = "None"; - events.trigger(self, "SyncplayError", [false]); - }; - } - - return new SyncplayManager(); -}); diff --git a/src/scripts/site.js b/src/scripts/site.js index 3a2ba13156..421cdae32e 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -314,6 +314,13 @@ var AppInfo = {}; return obj; } + function returnDefault(obj) { + if (obj.default === null) { + throw new Error("Object has no default!"); + } + return obj.default; + } + function getBowerPath() { return 'libraries'; } @@ -817,7 +824,7 @@ var AppInfo = {}; define('playbackSettings', [componentsPath + '/playbacksettings/playbacksettings'], returnFirstDependency); define('homescreenSettings', [componentsPath + '/homescreensettings/homescreensettings'], returnFirstDependency); define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager); - define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnFirstDependency); + define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnDefault); define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager); define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency); define('playMenu', [componentsPath + '/playmenu'], returnFirstDependency); From a18bca9d8c4a15383985d7a403b5564b88008921 Mon Sep 17 00:00:00 2001 From: gion Date: Sat, 4 Apr 2020 18:20:39 +0200 Subject: [PATCH 04/14] Implement syncplay permissions for a user --- src/components/syncplay/groupSelectionMenu.js | 55 +++++++++++++------ src/components/syncplay/syncplayManager.js | 6 +- src/controllers/useredit.js | 2 + src/scripts/librarymenu.js | 8 ++- src/strings/en-us.json | 6 ++ src/useredit.html | 10 ++++ 6 files changed, 64 insertions(+), 23 deletions(-) diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js index 671b76d5ea..046900d8e2 100644 --- a/src/components/syncplay/groupSelectionMenu.js +++ b/src/components/syncplay/groupSelectionMenu.js @@ -27,18 +27,17 @@ function emptyCallback() { /** * Used when user needs to join a group. * @param {HTMLElement} button - Element where to place the menu. + * @param {Object} user - Current user. + * @param {Object} apiClient - ApiClient. */ -function showNewJoinGroupSelection(button) { - var apiClient = connectionManager.currentApiClient(); - var sessionId = getActivePlayerId(); +function showNewJoinGroupSelection(button, user, apiClient) { + let sessionId = getActivePlayerId(); sessionId = sessionId ? sessionId : "none"; - - loading.show(); + const inSession = sessionId !== "none"; + const policy = user.localUser ? user.localUser.Policy : {}; apiClient.sendSyncplayCommand(sessionId, "ListGroups").then(function (response) { - response.json().then(function (groups) { - var inSession = sessionId !== "none"; - + response.json().then(function (groups) { var menuItems = groups.map(function (group) { var name = datetime.getDisplayRunningTime(group.PositionTicks); if (!inSession) { @@ -53,7 +52,7 @@ function showNewJoinGroupSelection(button) { }; }); - if (inSession) { + if (inSession && policy.SyncplayAccess === "CreateAndJoinGroups") { menuItems.push({ name: globalize.translate('LabelSyncplayNewGroup'), icon: "add", @@ -64,9 +63,15 @@ function showNewJoinGroupSelection(button) { } if (menuItems.length === 0) { + if (inSession && policy.SyncplayAccess === "JoinGroups") { + toast({ + text: globalize.translate('MessageSyncplayPermissionRequired') + }); + } else { toast({ text: globalize.translate('MessageSyncplayNoGroupsAvailable') }); + } loading.hide(); return; } @@ -92,20 +97,23 @@ function showNewJoinGroupSelection(button) { loading.hide(); }); }).catch(function (error) { - loading.hide(); console.error(error); + loading.hide(); + toast({ + text: globalize.translate('MessageSyncplayNoGroupsAvailable') + }); }); } /** * Used when user has joined a group. * @param {HTMLElement} button - Element where to place the menu. + * @param {Object} user - Current user. + * @param {Object} apiClient - ApiClient. */ -function showLeaveGroupSelection(button) { - const apiClient = connectionManager.currentApiClient(); +function showLeaveGroupSelection(button, user, apiClient) { const sessionId = getActivePlayerId(); - loading.show(); const menuItems = [{ name: globalize.translate('LabelSyncplayLeaveGroup'), @@ -143,9 +151,20 @@ events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) { * @param {HTMLElement} button - Element where to place the menu. */ export function show(button) { - if (syncplayEnabled) { - showLeaveGroupSelection(button); - } else { - showNewJoinGroupSelection(button); - } + loading.show(); + + const apiClient = connectionManager.currentApiClient(); + connectionManager.user(apiClient).then((user) => { + if (syncplayEnabled) { + showLeaveGroupSelection(button, user, apiClient); + } else { + showNewJoinGroupSelection(button, user, apiClient); + } + }).catch((error) => { + console.error(error); + loading.hide(); + toast({ + text: globalize.translate('MessageSyncplayNoGroupsAvailable') + }); + }); } diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncplayManager.js index bc0fe7bc6c..8619508e28 100644 --- a/src/components/syncplay/syncplayManager.js +++ b/src/components/syncplay/syncplayManager.js @@ -645,7 +645,7 @@ class SyncplayManager { /** * Converts server time to local time. - * @param {Date} server + * @param {Date} server The time to convert. * @returns {Date} Local time. */ serverDateToLocal (server) { @@ -655,7 +655,7 @@ class SyncplayManager { /** * Converts local time to server time. - * @param {Date} local + * @param {Date} local The time to convert. * @returns {Date} Server time. */ localDateToServer (local) { @@ -665,7 +665,7 @@ class SyncplayManager { /** * Gets Syncplay status. - * @returns {boolean} _true_ is user joined a group, _false_ otherwise. + * @returns {boolean} _true_ if user joined a group, _false_ otherwise. */ isSyncplayEnabled () { return this.syncplayEnabledAt !== null ? true : false; diff --git a/src/controllers/useredit.js b/src/controllers/useredit.js index 21ed60cfa9..cbe5acd36a 100644 --- a/src/controllers/useredit.js +++ b/src/controllers/useredit.js @@ -104,6 +104,7 @@ define(['jQuery', 'loading', 'libraryMenu', 'globalize', 'fnchecked'], function $('#chkEnableSharing', page).checked(user.Policy.EnablePublicSharing); $('#txtRemoteClientBitrateLimit', page).val(user.Policy.RemoteClientBitrateLimit / 1e6 || ''); $('#txtLoginAttemptsBeforeLockout', page).val(user.Policy.LoginAttemptsBeforeLockout || '0'); + $('#selectSyncplayAccess').val(user.Policy.SyncplayAccess); loading.hide(); } @@ -145,6 +146,7 @@ define(['jQuery', 'loading', 'libraryMenu', 'globalize', 'fnchecked'], function }).map(function (c) { return c.getAttribute('data-id'); }); + user.Policy.SyncplayAccess = page.querySelector('#selectSyncplayAccess').value; ApiClient.updateUser(user).then(function () { ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () { onSaveComplete(page, user); diff --git a/src/scripts/librarymenu.js b/src/scripts/librarymenu.js index 8afe5f10f1..1daa200b8b 100644 --- a/src/scripts/librarymenu.js +++ b/src/scripts/librarymenu.js @@ -86,6 +86,12 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' if (!layoutManager.tv) { headerCastButton.classList.remove('hide'); } + + var policy = user.Policy ? user.Policy : user.localUser.Policy; + + if (headerSyncButton && policy && policy.SyncplayAccess !== "None") { + headerSyncButton.classList.remove("hide"); + } } else { headerHomeButton.classList.add('hide'); headerCastButton.classList.add('hide'); @@ -95,8 +101,6 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' } } - headerSyncButton.classList.remove("hide"); - requiresUserRefresh = false; } diff --git a/src/strings/en-us.json b/src/strings/en-us.json index c7611333f0..83890426c5 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -862,6 +862,10 @@ "LabelSyncplayNewGroupDescription": "Create a new group", "LabelSyncplayLeaveGroup": "Leave group", "LabelSyncplayLeaveGroupDescription": "Disable Syncplay", + "LabelSyncplayAccessCreateAndJoinGroups": "Allow user to create and join groups", + "LabelSyncplayAccessJoinGroups": "Allow user to join groups", + "LabelSyncplayAccessNone": "Disabled for this user", + "LabelSyncplayAccess": "Syncplay access", "LabelTVHomeScreen": "TV mode home screen:", "LabelTag": "Tag:", "LabelTagline": "Tagline:", @@ -1031,6 +1035,7 @@ "MessageSyncplayUserLeft": "{0} left group.", "MessageSyncplayGroupWait": "{0} is buffering...", "MessageSyncplayNoGroupsAvailable": "No groups available.", + "MessageSyncplayPermissionRequired": "Permission required to create a group.", "Metadata": "Metadata", "MetadataManager": "Metadata Manager", "MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.", @@ -1379,6 +1384,7 @@ "Suggestions": "Suggestions", "Sunday": "Sunday", "Sync": "Sync", + "SyncplayAccessHelp": "Select the level of access this user has to the Syncplay feature. Syncplay enables to sync playback with other users.", "SystemDlnaProfilesHelp": "System profiles are read-only. Changes to a system profile will be saved to a new custom profile.", "TV": "TV", "TabAccess": "Access", diff --git a/src/useredit.html b/src/useredit.html index 0de3069dc7..5f759c973c 100644 --- a/src/useredit.html +++ b/src/useredit.html @@ -104,6 +104,16 @@
${LabelUserRemoteClientBitrateLimitHelp}
+
+
+ +
${SyncplayAccessHelp}
+
+

${HeaderAllowMediaDeletionFrom}

From 9fabbd5746c90b2115ae5b634159fb79d60abc5f Mon Sep 17 00:00:00 2001 From: gion Date: Wed, 15 Apr 2020 18:09:34 +0200 Subject: [PATCH 05/14] Capture playback permission --- gulpfile.js | 2 +- package.json | 7 +-- src/assets/audio/silence.wav | Bin 0 -> 88244 bytes src/components/syncplay/groupSelectionMenu.js | 11 ++++ .../syncplay/playbackPermissionManager.js | 51 ++++++++++++++++++ src/scripts/site.js | 1 + src/strings/en-us.json | 1 + webpack.dev.js | 4 ++ webpack.prod.js | 4 ++ 9 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/assets/audio/silence.wav create mode 100644 src/components/syncplay/playbackPermissionManager.js diff --git a/gulpfile.js b/gulpfile.js index 6c33167386..ad77d9a677 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,7 +45,7 @@ const options = { query: ['src/**/*.png', 'src/**/*.jpg', 'src/**/*.gif', 'src/**/*.svg'] }, copy: { - query: ['src/**/*.json', 'src/**/*.ico'] + query: ['src/**/*.json', 'src/**/*.ico', 'src/**/*.wav'] }, injectBundle: { query: 'src/index.html' diff --git a/package.json b/package.json index 39330cf600..19d3b05a45 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,9 @@ "src/components/playback/mediasession.js", "src/components/sanatizefilename.js", "src/components/scrollManager.js", + "src/components/syncplay/playbackPermissionManager.js", + "src/components/syncplay/groupSelectionMenu.js", + "src/components/syncplay/syncplayManager.js", "src/scripts/dfnshelper.js", "src/scripts/dom.js", "src/scripts/filesystem.js", @@ -105,9 +108,7 @@ "src/scripts/keyboardnavigation.js", "src/scripts/settings/appSettings.js", "src/scripts/settings/userSettings.js", - "src/scripts/settings/webSettings.js", - "src/components/syncplay/groupSelectionMenu.js", - "src/components/syncplay/syncplayManager.js" + "src/scripts/settings/webSettings.js" ], "plugins": [ "@babel/plugin-transform-modules-amd" diff --git a/src/assets/audio/silence.wav b/src/assets/audio/silence.wav new file mode 100644 index 0000000000000000000000000000000000000000..63f253da845e5177e59bcbc05a49ecb118f5e97a GIT binary patch literal 88244 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 Sj#^Qp#u#I*axXE^2mT*$^iC20 literal 0 HcmV?d00001 diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js index 046900d8e2..1b490db803 100644 --- a/src/components/syncplay/groupSelectionMenu.js +++ b/src/components/syncplay/groupSelectionMenu.js @@ -7,6 +7,7 @@ import datetime from 'datetime'; import toast from 'toast'; import actionsheet from 'actionsheet'; import globalize from 'globalize'; +import playbackPermissionManager from 'playbackPermissionManager'; /** * Gets active player id. @@ -153,6 +154,16 @@ events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) { export function show(button) { loading.show(); + // TODO: should feature be disabled if playback permission is missing? + playbackPermissionManager.check().then(() => { + console.debug("Playback is allowed."); + }).catch((error) => { + console.error("Playback not allowed!", error); + toast({ + text: globalize.translate("MessageSyncplayPlaybackPermissionRequired") + }); + }); + const apiClient = connectionManager.currentApiClient(); connectionManager.user(apiClient).then((user) => { if (syncplayEnabled) { diff --git a/src/components/syncplay/playbackPermissionManager.js b/src/components/syncplay/playbackPermissionManager.js new file mode 100644 index 0000000000..df16545b39 --- /dev/null +++ b/src/components/syncplay/playbackPermissionManager.js @@ -0,0 +1,51 @@ +/** + * Creates an audio element that plays a silent sound. + * @returns {HTMLMediaElement} The audio element. + */ +function createTestMediaElement () { + + const elem = document.createElement('audio'); + elem.classList.add('testMediaPlayerAudio'); + elem.classList.add('hide'); + + document.body.appendChild(elem); + + elem.volume = 1; // Volume should not be zero to trigger proper permissions + elem.src = "assets/audio/silence.wav"; // Silent sound + + return elem; +} + +/** + * Destroys a media element. + * @param {HTMLMediaElement} elem The element to destroy. + */ +function destroyTestMediaElement (elem) { + elem.pause(); + elem.remove(); +} + +/** + * Class that manages the playback permission. + */ +class PlaybackPermissionManager { + /** + * Tests playback permission. Grabs the permission when called inside a click event (or any other valid user interaction). + * @returns {Promise} Promise that resolves succesfully if playback permission is allowed. + */ + check () { + return new Promise((resolve, reject) => { + const media = createTestMediaElement(); + media.play().then(() => { + resolve(); + }).catch((error) => { + reject(error); + }).finally(() => { + destroyTestMediaElement(media); + }); + }); + } +} + +/** PlaybackPermissionManager singleton. */ +export default new PlaybackPermissionManager(); diff --git a/src/scripts/site.js b/src/scripts/site.js index 421cdae32e..3954b153a4 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -825,6 +825,7 @@ var AppInfo = {}; define('homescreenSettings', [componentsPath + '/homescreensettings/homescreensettings'], returnFirstDependency); define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager); define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnDefault); + define('playbackPermissionManager', [componentsPath + '/syncplay/playbackPermissionManager'], returnDefault); define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager); define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency); define('playMenu', [componentsPath + '/playmenu'], returnFirstDependency); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 83890426c5..136a015430 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1036,6 +1036,7 @@ "MessageSyncplayGroupWait": "{0} is buffering...", "MessageSyncplayNoGroupsAvailable": "No groups available.", "MessageSyncplayPermissionRequired": "Permission required to create a group.", + "MessageSyncplayPlaybackPermissionRequired": "Playback permission required.", "Metadata": "Metadata", "MetadataManager": "Metadata Manager", "MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.", diff --git a/webpack.dev.js b/webpack.dev.js index 76a1a7a752..d8879fe808 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -44,6 +44,10 @@ module.exports = merge(common, { use: [ 'file-loader' ] + }, + { + test: /\.(wav)$/i, + use: ["file-loader"] } ] } diff --git a/webpack.prod.js b/webpack.prod.js index f5c7accd04..cc4c57b9f4 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -37,6 +37,10 @@ module.exports = merge(common, { use: [ 'file-loader' ] + }, + { + test: /\.(wav)$/i, + use: ["file-loader"] } ] } From 06e6c99c03f70023e40e35bd2804e3916e2ec0de Mon Sep 17 00:00:00 2001 From: gion Date: Wed, 15 Apr 2020 18:15:28 +0200 Subject: [PATCH 06/14] Disable syncing after several attempts Refactor syncplay manager Attempt fixing Safari issues (timeupdate event not firing) --- src/components/syncplay/groupSelectionMenu.js | 29 +- src/components/syncplay/syncplayManager.js | 428 +++++++++++++----- src/workers/syncplay/syncplay.worker.js | 90 ++++ webpack.dev.js | 4 + webpack.prod.js | 4 + 5 files changed, 437 insertions(+), 118 deletions(-) create mode 100644 src/workers/syncplay/syncplay.worker.js diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js index 1b490db803..c907d338a4 100644 --- a/src/components/syncplay/groupSelectionMenu.js +++ b/src/components/syncplay/groupSelectionMenu.js @@ -13,7 +13,7 @@ import playbackPermissionManager from 'playbackPermissionManager'; * Gets active player id. * @returns {string} The player's id. */ -function getActivePlayerId() { +function getActivePlayerId () { var info = playbackManager.getPlayerInfo(); return info ? info.id : null; } @@ -21,7 +21,7 @@ function getActivePlayerId() { /** * Used to avoid console logs about uncaught promises */ -function emptyCallback() { +function emptyCallback () { // avoid console logs about uncaught promises } @@ -31,15 +31,23 @@ function emptyCallback() { * @param {Object} user - Current user. * @param {Object} apiClient - ApiClient. */ -function showNewJoinGroupSelection(button, user, apiClient) { +function showNewJoinGroupSelection (button, user, apiClient) { let sessionId = getActivePlayerId(); sessionId = sessionId ? sessionId : "none"; const inSession = sessionId !== "none"; 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) { response.json().then(function (groups) { var menuItems = groups.map(function (group) { + // TODO: update running time if group is playing? var name = datetime.getDisplayRunningTime(group.PositionTicks); if (!inSession) { name = group.PlayingItemName; @@ -90,7 +98,8 @@ function showNewJoinGroupSelection(button, user, apiClient) { apiClient.sendSyncplayCommand(sessionId, "NewGroup"); } else { apiClient.sendSyncplayCommand(sessionId, "JoinGroup", { - GroupId: id + GroupId: id, + PlayingItemId: playingItemId }); } }, emptyCallback); @@ -112,8 +121,16 @@ function showNewJoinGroupSelection(button, user, apiClient) { * @param {Object} user - Current user. * @param {Object} apiClient - ApiClient. */ -function showLeaveGroupSelection(button, user, apiClient) { +function showLeaveGroupSelection (button, user, apiClient) { const sessionId = getActivePlayerId(); + if (!sessionId) { + syncplayManager.signalError(); + toast({ + // TODO: translate + text: "Syncplay error occured." + }); + return; + } const menuItems = [{ @@ -151,7 +168,7 @@ events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) { * Shows a menu to handle Syncplay groups. * @param {HTMLElement} button - Element where to place the menu. */ -export function show(button) { +export function show (button) { loading.show(); // TODO: should feature be disabled if playback permission is missing? diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncplayManager.js index 8619508e28..86b7ec34af 100644 --- a/src/components/syncplay/syncplayManager.js +++ b/src/components/syncplay/syncplayManager.js @@ -32,7 +32,7 @@ function waitForEvent(emitter, eventType) { * @returns {string} The player's id. */ function getActivePlayerId() { - var info = playbackManager.getPlayerInfo(); + var info = playbackManager.getPlayerInfo(); return info ? info.id : null; } @@ -42,7 +42,9 @@ function getActivePlayerId() { 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 +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 @@ -60,6 +62,9 @@ class SyncplayManager { this.syncEnabled = false; this.playbackDiffMillis = 0; // 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.minBufferingThresholdMillis = 1000; @@ -86,7 +91,16 @@ class SyncplayManager { events.on(playbackManager, "playerchange", () => { this.onPlayerChange(); }); + + events.on(playbackManager, "playbackstart", (player, state) => { + events.trigger(this, 'PlaybackStart', [player, state]); + }); + this.bindToPlayer(playbackManager.getCurrentPlayer()); + + events.on(this, "TimeUpdate", (event) => { + this.syncPlaybackTime(); + }); } /** @@ -110,53 +124,9 @@ class SyncplayManager { * @param {Object} e The time update event. */ 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]); - - 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) { 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); - }); - }); + this.prepareSession(apiClient, cmd.GroupId, cmd.Data); break; case 'UserJoined': toast({ @@ -296,31 +239,12 @@ class SyncplayManager { }); 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(); + const enabledAt = new Date(cmd.Data); + this.enableSyncplay(apiClient, enabledAt, true); 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(); + this.disableSyncplay(true); break; case 'GroupWait': toast({ @@ -355,8 +279,9 @@ class SyncplayManager { } 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); 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. * @param {Date} playAtTime The server's UTC time at which to resume playback. @@ -408,8 +441,9 @@ class SyncplayManager { playbackManager.syncplay_unpause(); this.syncTimeout = setTimeout(() => { - this.syncEnabled = true - }, this.syncMethodThreshold / 2); + this.syncEnabled = true; + this.startSyncWatcher(); + }, SyncMethodThreshold / 2); }, playTimeout); @@ -421,8 +455,9 @@ class SyncplayManager { playbackManager.syncplay_seek(serverPositionTicks); this.syncTimeout = setTimeout(() => { - this.syncEnabled = true - }, this.syncMethodThreshold / 2); + this.syncEnabled = true; + this.startSyncWatcher(); + }, SyncMethodThreshold / 2); } } @@ -471,6 +506,7 @@ class SyncplayManager { clearTimeout(this.syncTimeout); this.syncEnabled = false; + this.stopSyncWatcher(); if (this.currentPlayer) { this.currentPlayer.setPlaybackRate(1); } @@ -587,6 +623,15 @@ class SyncplayManager { 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(); @@ -614,6 +659,13 @@ class SyncplayManager { this.requestPing(); }); + }).catch((error) => { + console.error(error); + this.signalError(); + toast({ + // TODO: translate + text: "Syncplay error occured." + }); }); }, this.pingIntervalTimeout); @@ -627,7 +679,7 @@ class SyncplayManager { this.notifySyncplayReady = true; this.pingStop = false; this.initTimeDiff = this.initTimeDiff > this.greedyPingCount ? 1 : this.initTimeDiff; - this.pingIntervalTimeout = this.pingIntervalTimeoutGreedy; + this.pingIntervalTimeout = PingIntervalTimeoutGreedy; 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. * @param {Date} server The time to convert. @@ -663,14 +868,6 @@ class SyncplayManager { 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. * @returns {Object} The Syncplay stats. @@ -698,6 +895,13 @@ class SyncplayManager { this.syncMethod = "None"; events.trigger(this, "SyncplayError", [false]); } + + /** + * Signals an error state, which disables and resets Syncplay for a new session. + */ + signalError () { + this.disableSyncplay(); + } } /** SyncplayManager singleton. */ diff --git a/src/workers/syncplay/syncplay.worker.js b/src/workers/syncplay/syncplay.worker.js new file mode 100644 index 0000000000..bd8d2bd60f --- /dev/null +++ b/src/workers/syncplay/syncplay.worker.js @@ -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); +}); diff --git a/webpack.dev.js b/webpack.dev.js index d8879fe808..544d32d63e 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -48,6 +48,10 @@ module.exports = merge(common, { { test: /\.(wav)$/i, use: ["file-loader"] + }, + { + test: /\.worker.js$/, + use: ["worker"] } ] } diff --git a/webpack.prod.js b/webpack.prod.js index cc4c57b9f4..7b5f2ff6c0 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -41,6 +41,10 @@ module.exports = merge(common, { { test: /\.(wav)$/i, use: ["file-loader"] + }, + { + test: /\.worker.js$/, + use: ["worker"] } ] } From 5342b90a56a7aeeacbf9edaa464ab74e9ecf8aca Mon Sep 17 00:00:00 2001 From: gion Date: Thu, 16 Apr 2020 16:05:04 +0200 Subject: [PATCH 07/14] Implement NTP like time sync --- src/components/playerstats/playerstats.js | 4 +- src/components/syncplay/syncplayManager.js | 176 +++-------------- src/components/syncplay/timeSyncManager.js | 209 +++++++++++++++++++++ src/scripts/site.js | 1 + src/strings/en-us.json | 2 +- 5 files changed, 236 insertions(+), 156 deletions(-) create mode 100644 src/components/syncplay/timeSyncManager.js diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index 4bd49ba5c1..404baab7eb 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -332,8 +332,8 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplay var stats = syncplayManager.getStats(); syncStats.push({ - label: globalize.translate("LabelSyncplayTimeDiff"), - value: stats.TimeDiff + "ms" + label: globalize.translate("LabelSyncplayTimeOffset"), + value: stats.TimeOffset + "ms" }); syncStats.push({ diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncplayManager.js index 86b7ec34af..de1424b89f 100644 --- a/src/components/syncplay/syncplayManager.js +++ b/src/components/syncplay/syncplayManager.js @@ -8,6 +8,7 @@ import events from 'events'; import connectionManager from 'connectionManager'; import playbackManager from 'playbackManager'; +import timeSyncManager from 'timeSyncManager'; import toast from 'toast'; import globalize from 'globalize'; @@ -80,11 +81,7 @@ class SyncplayManager { 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.timeOffsetWithServer = 0; // server time minus local time this.roundTripDuration = 0; this.notifySyncplayReady = false; @@ -101,6 +98,17 @@ class SyncplayManager { events.on(this, "TimeUpdate", (event) => { 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) { this.syncplayEnabledAt = enabledAt; - this.syncplayReady = false; + this.injectPlaybackManager(); events.trigger(this, "SyncplayEnabled", [true]); + waitForEvent(this, "SyncplayReady").then(() => { this.processCommand(this.queuedCommand, apiClient); this.queuedCommand = null; }); - this.injectPlaybackManager(); - this.startPing(); + this.syncplayReady = false; + this.notifySyncplayReady = true; + + timeSyncManager.forceUpdate(); if (showMessage) { toast({ @@ -405,7 +416,6 @@ class SyncplayManager { this.syncEnabled = false; events.trigger(this, "SyncplayEnabled", [false]); this.restorePlaybackManager(); - this.stopPing(); this.stopSyncWatcher(); if (showMessage) { @@ -431,7 +441,7 @@ class SyncplayManager { schedulePlay (playAtTime, positionTicks) { this.clearScheduledCommand(); var currentTime = new Date(); - var playAtTimeLocal = this.serverDateToLocal(playAtTime); + var playAtTimeLocal = timeSyncManager.serverDateToLocal(playAtTime); if (playAtTimeLocal > currentTime) { var playTimeout = playAtTimeLocal - currentTime; @@ -469,7 +479,7 @@ class SyncplayManager { schedulePause (pauseAtTime, positionTicks) { this.clearScheduledCommand(); var currentTime = new Date(); - var pauseAtTimeLocal = this.serverDateToLocal(pauseAtTime); + var pauseAtTimeLocal = timeSyncManager.serverDateToLocal(pauseAtTime); if (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. * @@ -722,7 +612,7 @@ class SyncplayManager { const CurrentPositionTicks = playbackManager.currentTime(); // 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 // diff might be caused by the player internally starting the playback 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. * @returns {Object} The Syncplay stats. */ getStats () { return { - TimeDiff: this.timeDiff, + TimeOffset: this.timeOffsetWithServer, PlaybackDiff: this.playbackDiffMillis, SyncMethod: this.syncMethod } diff --git a/src/components/syncplay/timeSyncManager.js b/src/components/syncplay/timeSyncManager.js new file mode 100644 index 0000000000..e526aa5fa4 --- /dev/null +++ b/src/components/syncplay/timeSyncManager.js @@ -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(); diff --git a/src/scripts/site.js b/src/scripts/site.js index 3954b153a4..ecfeb27349 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -824,6 +824,7 @@ var AppInfo = {}; define('playbackSettings', [componentsPath + '/playbacksettings/playbacksettings'], returnFirstDependency); define('homescreenSettings', [componentsPath + '/homescreensettings/homescreensettings'], returnFirstDependency); define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager); + define('timeSyncManager', [componentsPath + '/syncplay/timeSyncManager'], returnDefault); define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnDefault); define('playbackPermissionManager', [componentsPath + '/syncplay/playbackPermissionManager'], returnDefault); define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 136a015430..43b5f14148 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -855,7 +855,7 @@ "LabelSubtitlePlaybackMode": "Subtitle mode:", "LabelSubtitles": "Subtitles", "LabelSupportedMediaTypes": "Supported Media Types:", - "LabelSyncplayTimeDiff": "Time difference with server:", + "LabelSyncplayTimeOffset": "Time offset with server:", "LabelSyncplayPlaybackDiff": "Playback time difference:", "LabelSyncplaySyncMethod": "Sync method:", "LabelSyncplayNewGroup": "New group", From 9839dcd02ad8d46ef19ad6b24b859552e816dd65 Mon Sep 17 00:00:00 2001 From: gion Date: Fri, 17 Apr 2020 13:42:46 +0200 Subject: [PATCH 08/14] Update session ping --- package.json | 1 + src/components/syncplay/syncplayManager.js | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 19d3b05a45..33a7d2b004 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "src/components/scrollManager.js", "src/components/syncplay/playbackPermissionManager.js", "src/components/syncplay/groupSelectionMenu.js", + "src/components/syncplay/timeSyncManager.js", "src/components/syncplay/syncplayManager.js", "src/scripts/dfnshelper.js", "src/scripts/dom.js", diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncplayManager.js index de1424b89f..167f79c2ab 100644 --- a/src/components/syncplay/syncplayManager.js +++ b/src/components/syncplay/syncplayManager.js @@ -99,7 +99,7 @@ class SyncplayManager { this.syncPlaybackTime(); }); - events.on(timeSyncManager, "Update", (event, timeOffset, ping) => { + events.on(timeSyncManager, "Update", (event, timeOffset, ping) => { this.timeOffsetWithServer = timeOffset; this.roundTripDuration = ping * 2; @@ -108,6 +108,25 @@ class SyncplayManager { events.trigger(this, "SyncplayReady"); this.notifySyncplayReady = false; } + + // Report ping + if (this.syncEnabled) { + const apiClient = connectionManager.currentApiClient(); + const sessionId = getActivePlayerId(); + + if (!sessionId) { + this.signalError(); + toast({ + // TODO: translate + text: "Syncplay error occured." + }); + return; + } + + apiClient.sendSyncplayCommand(sessionId, "UpdatePing", { + Ping: ping + }); + } }); } From 4eedbe57427d43a96ca78f2ca27b4ff043ec8a1c Mon Sep 17 00:00:00 2001 From: gion Date: Fri, 17 Apr 2020 19:41:02 +0200 Subject: [PATCH 09/14] Fix sign in date conversion --- src/components/syncplay/timeSyncManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/syncplay/timeSyncManager.js b/src/components/syncplay/timeSyncManager.js index e526aa5fa4..74c98820c2 100644 --- a/src/components/syncplay/timeSyncManager.js +++ b/src/components/syncplay/timeSyncManager.js @@ -191,7 +191,7 @@ class TimeSyncManager { */ serverDateToLocal(server) { // server - local = offset - return new Date(server.getTime() + this.getTimeOffset()); + return new Date(server.getTime() - this.getTimeOffset()); } /** @@ -201,7 +201,7 @@ class TimeSyncManager { */ localDateToServer(local) { // server - local = offset - return new Date(local.getTime() - this.getTimeOffset()); + return new Date(local.getTime() + this.getTimeOffset()); } } From 71f72f836c508acb758bd18f06d43e4d9bf5857a Mon Sep 17 00:00:00 2001 From: gion Date: Mon, 20 Apr 2020 20:03:27 +0200 Subject: [PATCH 10/14] Remove syncplay worker This was an attempt to fix issues on Safari --- src/components/syncplay/syncplayManager.js | 78 ------------------- src/workers/syncplay/syncplay.worker.js | 90 ---------------------- webpack.dev.js | 4 - webpack.prod.js | 4 - 4 files changed, 176 deletions(-) delete mode 100644 src/workers/syncplay/syncplay.worker.js diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncplayManager.js index 167f79c2ab..0ca7c13149 100644 --- a/src/components/syncplay/syncplayManager.js +++ b/src/components/syncplay/syncplayManager.js @@ -89,10 +89,6 @@ class SyncplayManager { this.onPlayerChange(); }); - events.on(playbackManager, "playbackstart", (player, state) => { - events.trigger(this, 'PlaybackStart', [player, state]); - }); - this.bindToPlayer(playbackManager.getCurrentPlayer()); events.on(this, "TimeUpdate", (event) => { @@ -359,7 +355,6 @@ class SyncplayManager { startIndex: sessionData.StartIndex, serverId: serverId }).then(() => { - // TODO: switch to PlaybackStart maybe? waitForEvent(this, "PlayerChange").then(() => { playbackManager.pause(); var sessionId = getActivePlayerId(); @@ -435,7 +430,6 @@ class SyncplayManager { this.syncEnabled = false; events.trigger(this, "SyncplayEnabled", [false]); this.restorePlaybackManager(); - this.stopSyncWatcher(); if (showMessage) { toast({ @@ -471,7 +465,6 @@ class SyncplayManager { this.syncTimeout = setTimeout(() => { this.syncEnabled = true; - this.startSyncWatcher(); }, SyncMethodThreshold / 2); }, playTimeout); @@ -485,7 +478,6 @@ class SyncplayManager { this.syncTimeout = setTimeout(() => { this.syncEnabled = true; - this.startSyncWatcher(); }, SyncMethodThreshold / 2); } } @@ -535,7 +527,6 @@ class SyncplayManager { clearTimeout(this.syncTimeout); this.syncEnabled = false; - this.stopSyncWatcher(); if (this.currentPlayer) { this.currentPlayer.setPlaybackRate(1); } @@ -625,7 +616,6 @@ class SyncplayManager { const elapsed = currentTime - this.lastSyncTime; if (elapsed < SyncMethodThreshold / 2) return; this.lastSyncTime = currentTime; - this.notifySyncWatcher(); const playAtTime = this.lastCommand.When; @@ -689,74 +679,6 @@ class SyncplayManager { } } - /** - * 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 - }); - } - } - /** * Gets Syncplay stats. * @returns {Object} The Syncplay stats. diff --git a/src/workers/syncplay/syncplay.worker.js b/src/workers/syncplay/syncplay.worker.js deleted file mode 100644 index bd8d2bd60f..0000000000 --- a/src/workers/syncplay/syncplay.worker.js +++ /dev/null @@ -1,90 +0,0 @@ -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); -}); diff --git a/webpack.dev.js b/webpack.dev.js index 544d32d63e..d8879fe808 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -48,10 +48,6 @@ module.exports = merge(common, { { test: /\.(wav)$/i, use: ["file-loader"] - }, - { - test: /\.worker.js$/, - use: ["worker"] } ] } diff --git a/webpack.prod.js b/webpack.prod.js index 7b5f2ff6c0..cc4c57b9f4 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -41,10 +41,6 @@ module.exports = merge(common, { { test: /\.(wav)$/i, use: ["file-loader"] - }, - { - test: /\.worker.js$/, - use: ["worker"] } ] } From a2ba96ab820c5b3e8d3050e77952d48eee2be776 Mon Sep 17 00:00:00 2001 From: gion Date: Wed, 22 Apr 2020 22:48:26 +0200 Subject: [PATCH 11/14] Handle error messages --- src/components/syncplay/groupSelectionMenu.js | 4 ++-- src/components/syncplay/syncplayManager.js | 20 ++++++++++++++++++- src/strings/en-us.json | 7 +++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js index c907d338a4..be54d92214 100644 --- a/src/components/syncplay/groupSelectionMenu.js +++ b/src/components/syncplay/groupSelectionMenu.js @@ -57,7 +57,7 @@ function showNewJoinGroupSelection (button, user, apiClient) { icon: "group", id: group.GroupId, selected: false, - secondaryText: group.Partecipants.join(", ") + secondaryText: group.Participants.join(", ") }; }); @@ -74,7 +74,7 @@ function showNewJoinGroupSelection (button, user, apiClient) { if (menuItems.length === 0) { if (inSession && policy.SyncplayAccess === "JoinGroups") { toast({ - text: globalize.translate('MessageSyncplayPermissionRequired') + text: globalize.translate('MessageSyncplayCreateGroupDenied') }); } else { toast({ diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncplayManager.js index 0ca7c13149..0de232108b 100644 --- a/src/components/syncplay/syncplayManager.js +++ b/src/components/syncplay/syncplayManager.js @@ -274,7 +274,25 @@ class SyncplayManager { text: globalize.translate('MessageSyncplayGroupWait', cmd.Data) }); break; - case 'KeepAlive': + case 'GroupNotJoined': + toast({ + text: globalize.translate('MessageSyncplayGroupNotJoined', cmd.Data) + }); + break; + case 'CreateGroupDenied': + toast({ + text: globalize.translate('MessageSyncplayCreateGroupDenied', cmd.Data) + }); + break; + case 'JoinGroupDenied': + toast({ + text: globalize.translate('MessageSyncplayJoinGroupDenied', cmd.Data) + }); + break; + case 'LibraryAccessDenied': + toast({ + text: globalize.translate('MessageSyncplayLibraryAccessDenied', cmd.Data) + }); break; default: console.error('processSyncplayGroupUpdate does not recognize: ' + cmd.Type); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 43b5f14148..fb72e8881c 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1034,9 +1034,12 @@ "MessageSyncplayUserJoined": "{0} joined group.", "MessageSyncplayUserLeft": "{0} left group.", "MessageSyncplayGroupWait": "{0} is buffering...", - "MessageSyncplayNoGroupsAvailable": "No groups available.", - "MessageSyncplayPermissionRequired": "Permission required to create a group.", + "MessageSyncplayNoGroupsAvailable": "No groups available. Start playing something first.", "MessageSyncplayPlaybackPermissionRequired": "Playback permission required.", + "MessageSyncplayGroupNotJoined": "Failed to join requested group.", + "MessageSyncplayCreateGroupDenied": "Permission required to create a group.", + "MessageSyncplayJoinGroupDenied": "Permission required to use Syncplay.", + "MessageSyncplayLibraryAccessDenied": "Access to this content is restricted.", "Metadata": "Metadata", "MetadataManager": "Metadata Manager", "MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.", From 11f6217bb22072b3196b1406ad2b321f01ea3839 Mon Sep 17 00:00:00 2001 From: gion Date: Tue, 5 May 2020 12:01:43 +0200 Subject: [PATCH 12/14] Fix code issues --- gulpfile.js | 2 +- src/assets/audio/silence.mp3 | Bin 0 -> 4890 bytes src/assets/audio/silence.wav | Bin 88244 -> 0 bytes src/components/htmlaudioplayer/plugin.js | 4 +- src/components/htmlvideoplayer/plugin.js | 4 +- src/components/playback/playbackmanager.js | 19 +- src/components/playerstats/playerstats.js | 10 +- src/components/serverNotifications.js | 4 +- src/components/syncplay/groupSelectionMenu.js | 77 ++-- .../syncplay/playbackPermissionManager.js | 4 +- src/components/syncplay/syncplayManager.js | 356 +++++++++++------- src/components/syncplay/timeSyncManager.js | 40 +- src/scripts/librarymenu.js | 36 +- src/scripts/site.js | 5 +- src/strings/en-us.json | 13 +- webpack.dev.js | 4 +- webpack.prod.js | 4 +- 17 files changed, 340 insertions(+), 242 deletions(-) create mode 100644 src/assets/audio/silence.mp3 delete mode 100644 src/assets/audio/silence.wav diff --git a/gulpfile.js b/gulpfile.js index ad77d9a677..538497d4d0 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,7 +45,7 @@ const options = { query: ['src/**/*.png', 'src/**/*.jpg', 'src/**/*.gif', 'src/**/*.svg'] }, copy: { - query: ['src/**/*.json', 'src/**/*.ico', 'src/**/*.wav'] + query: ['src/**/*.json', 'src/**/*.ico', 'src/**/*.mp3'] }, injectBundle: { query: 'src/index.html' diff --git a/src/assets/audio/silence.mp3 b/src/assets/audio/silence.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..29dbef21856b7e607cab432056a8fa10315b496b GIT binary patch literal 4890 zcmd^?dr(wW9LLXkeKcmi5QiKgf>i+va1=&`l&2ssmzRQ~IJBh2q(cfi4$8`^VHm#2 zVH|Q%gjT^oCDM3ZaZ>UD>H?xck`=7DlMPT^VsGc}VXr^D?6O$>)tzDQ-QPL;``qvE z{C>Z4V0#bLpck*G_cyG=wh8#p4k5E?Xw)c6OIur8dwVA*Cl?ntH#bjDKR>_V;PCM9 z=;+wk*!cK_goMPzEnBv1-=3D1mY$xOnR(<$c6RoO6Q@s~K6|#Lq@=8@qN1Xvrna`W zuCAe>;o-x!wzl?mjYiYe#V`!ZVwQW5XRz;F$2rcc8A zKk$FKv%M2*5LJalTCCU_p~Y1Qjby>q_eRT6K5&uz1!l0FZ27B$OyTEp4=hdud%2#o z2*q;&9x!8&FA$=v49SZa@4$1E|JwBrU4oa?mP#9w?U-Uq-sK>o1~CB_tTts;k=>gE zEUJC24!ccu34i)vef`>;&5Ba-$I$AXIe4KF74P;Eum0}!^q`UxhZX~CLHKQZ$1D5FCcdWP z*M|!a$9cr->0cj%anlU|ugt=Yo0RW1#EhR{F?mEQ7+}{d73M()Rs9#KpA|AG4x6#K zL0{k2#)T=&=(A>G2XAO^x^>xtk#`I9bWpTZq|1Y)L*xM$fah1fQt{6GM&HL0apK13 zR<;*m<2Dec)hgDMj%i7rJ_n#!hK=y&Ir=)qdL%?o zplSK01TK3ToBVHPr3Zx^odv9H-{-4&4)@wz9&}Q40nxKUrVF28Jrqoz#)+)BxVe*!Zs_{yY}F*Ru6CNe;K1I@N&q_J9e8hN z+-xh6^P`JAN3Z9Joc@Dw0*v)h;7*l$%-#2^;{1l`W2%ciAH{;99(A67(oO%Qs(2xX zqU)&XoSu*&^9^`pRh@C(3%oYGk_wCpS{b=5bc*ZfvBG}PE~k$ZJswD0g>gpX2Ar}_ z?@al^Y8kW?-0|Jz=o(E+y3GZ8Fy|YQHTI9(khBWl4CjGv1p4j1F!nc%@Aa@HsleLj zy(yFt_8pvWDJ0+4AYFemz!5DbzL>7w@9|UT;iTD@-nv$nO02q1ChT&Yv~m3uXNg0e zKo9OaH?QJ+TTvBbVx7XSz^a-uT+Fioj6E5faJ}+~fTN+VNJt(;kUBM>hc`o?y%j5K z@`0gMrBr<=_MO=a0(YWD3h-#U7ez`<4hL}3m=jk4_SEC9ec@7r^ZvVl0BFH&0z_dJo z944xGWxz+N&=k<<^Pu^l4bRcX4X@8UvY+~q^)apoy43EG{Hd-4mit@p2i~=sHg%@O zT%M!XgX`xw(Gy}?KOpGSk(;Qx*DaDN;567M_IPyPn!oDb;$ literal 0 HcmV?d00001 diff --git a/src/assets/audio/silence.wav b/src/assets/audio/silence.wav deleted file mode 100644 index 63f253da845e5177e59bcbc05a49ecb118f5e97a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 88244 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 Sj#^Qp#u#I*axXE^2mT*$^iC20 diff --git a/src/components/htmlaudioplayer/plugin.js b/src/components/htmlaudioplayer/plugin.js index 2580481051..672bd06b8d 100644 --- a/src/components/htmlaudioplayer/plugin.js +++ b/src/components/htmlaudioplayer/plugin.js @@ -520,8 +520,8 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp var list = []; var audio = document.createElement('audio'); - if (typeof audio.playbackRate === "number") { - list.push("PlaybackRate"); + if (typeof audio.playbackRate === 'number') { + list.push('PlaybackRate'); } return list; diff --git a/src/components/htmlvideoplayer/plugin.js b/src/components/htmlvideoplayer/plugin.js index 064e4155ee..60f39c5ecc 100644 --- a/src/components/htmlvideoplayer/plugin.js +++ b/src/components/htmlvideoplayer/plugin.js @@ -1442,8 +1442,8 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa list.push('AirPlay'); } - if (typeof video.playbackRate === "number") { - list.push("PlaybackRate"); + if (typeof video.playbackRate === 'number') { + list.push('PlaybackRate'); } list.push('SetBrightness'); diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index e4ce40cf4e..ec0ee41402 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -54,6 +54,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla if (!serverId) { // Not a server item // We can expand on this later and possibly report them + events.trigger(playbackManagerInstance, 'reportplayback', [false]); return; } @@ -77,7 +78,11 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla } var apiClient = connectionManager.getApiClient(serverId); - apiClient[method](info); + var reportPlaybackPromise = apiClient[method](info); + // Notify that report has been sent + reportPlaybackPromise.then(() => { + events.trigger(playbackManagerInstance, 'reportplayback', [true]); + }); } function getPlaylistSync(playbackManagerInstance, player) { @@ -3777,18 +3782,14 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla } }; - PlaybackManager.prototype.setPlaybackRate = function (value, player) { - player = player || this._currentPlayer; - - if (player) { + PlaybackManager.prototype.setPlaybackRate = function (value, player = this._currentPlayer) { + if (player && player.setPlaybackRate) { player.setPlaybackRate(value); } }; - PlaybackManager.prototype.getPlaybackRate = function (player) { - player = player || this._currentPlayer; - - if (player) { + PlaybackManager.prototype.getPlaybackRate = function (player = this._currentPlayer) { + if (player && player.getPlaybackRate) { return player.getPlaybackRate(); } diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index 404baab7eb..07fcd7070a 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -332,17 +332,17 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplay var stats = syncplayManager.getStats(); syncStats.push({ - label: globalize.translate("LabelSyncplayTimeOffset"), - value: stats.TimeOffset + "ms" + label: globalize.translate('LabelSyncplayTimeOffset'), + value: stats.TimeOffset + globalize.translate('MillisecondsUnit') }); syncStats.push({ - label: globalize.translate("LabelSyncplayPlaybackDiff"), - value: stats.PlaybackDiff + "ms" + label: globalize.translate('LabelSyncplayPlaybackDiff'), + value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit') }); syncStats.push({ - label: globalize.translate("LabelSyncplaySyncMethod"), + label: globalize.translate('LabelSyncplaySyncMethod'), value: stats.SyncMethod }); diff --git a/src/components/serverNotifications.js b/src/components/serverNotifications.js index 9776c88bd3..876c3f7e79 100644 --- a/src/components/serverNotifications.js +++ b/src/components/serverNotifications.js @@ -187,9 +187,9 @@ define(['connectionManager', 'playbackManager', 'syncplayManager', 'events', 'in events.trigger(serverNotifications, 'UserDataChanged', [apiClient, msg.Data.UserDataList[i]]); } } - } else if (msg.MessageType === "SyncplayCommand") { + } else if (msg.MessageType === 'SyncplayCommand') { syncplayManager.processCommand(msg.Data, apiClient); - } else if (msg.MessageType === "SyncplayGroupUpdate") { + } else if (msg.MessageType === 'SyncplayGroupUpdate') { syncplayManager.processGroupUpdate(msg.Data, apiClient); } else { events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]); diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js index be54d92214..af08f92776 100644 --- a/src/components/syncplay/groupSelectionMenu.js +++ b/src/components/syncplay/groupSelectionMenu.js @@ -3,7 +3,6 @@ import connectionManager from 'connectionManager'; import playbackManager from 'playbackManager'; import syncplayManager from 'syncplayManager'; import loading from 'loading'; -import datetime from 'datetime'; import toast from 'toast'; import actionsheet from 'actionsheet'; import globalize from 'globalize'; @@ -18,13 +17,6 @@ function getActivePlayerId () { return info ? info.id : null; } -/** - * Used to avoid console logs about uncaught promises - */ -function emptyCallback () { - // avoid console logs about uncaught promises -} - /** * Used when user needs to join a group. * @param {HTMLElement} button - Element where to place the menu. @@ -32,47 +24,43 @@ function emptyCallback () { * @param {Object} apiClient - ApiClient. */ function showNewJoinGroupSelection (button, user, apiClient) { - let sessionId = getActivePlayerId(); - sessionId = sessionId ? sessionId : "none"; - const inSession = sessionId !== "none"; + const sessionId = getActivePlayerId() || 'none'; + const inSession = sessionId !== 'none'; const policy = user.localUser ? user.localUser.Policy : {}; let playingItemId; try { const playState = playbackManager.getPlayerState(); playingItemId = playState.NowPlayingItem.Id; + console.debug('Item', playingItemId, 'is currently playing.'); } catch (error) { - playingItemId = ""; + playingItemId = ''; + console.debug('No item is currently playing.'); } - apiClient.sendSyncplayCommand(sessionId, "ListGroups").then(function (response) { - response.json().then(function (groups) { + apiClient.sendSyncplayCommand(sessionId, 'ListGroups').then(function (response) { + response.json().then(function (groups) { var menuItems = groups.map(function (group) { - // TODO: update running time if group is playing? - var name = datetime.getDisplayRunningTime(group.PositionTicks); - if (!inSession) { - name = group.PlayingItemName; - } return { - name: name, - icon: "group", + name: group.PlayingItemName, + icon: 'group', id: group.GroupId, selected: false, - secondaryText: group.Participants.join(", ") + secondaryText: group.Participants.join(', ') }; }); - if (inSession && policy.SyncplayAccess === "CreateAndJoinGroups") { + if (inSession && policy.SyncplayAccess === 'CreateAndJoinGroups') { menuItems.push({ name: globalize.translate('LabelSyncplayNewGroup'), - icon: "add", - id: "new-group", + icon: 'add', + id: 'new-group', selected: true, secondaryText: globalize.translate('LabelSyncplayNewGroupDescription') }); } if (menuItems.length === 0) { - if (inSession && policy.SyncplayAccess === "JoinGroups") { + if (inSession && policy.SyncplayAccess === 'JoinGroups') { toast({ text: globalize.translate('MessageSyncplayCreateGroupDenied') }); @@ -94,15 +82,17 @@ function showNewJoinGroupSelection (button, user, apiClient) { }; actionsheet.show(menuOptions).then(function (id) { - if (id == "new-group") { - apiClient.sendSyncplayCommand(sessionId, "NewGroup"); + if (id == 'new-group') { + apiClient.sendSyncplayCommand(sessionId, 'NewGroup'); } else { - apiClient.sendSyncplayCommand(sessionId, "JoinGroup", { + apiClient.sendSyncplayCommand(sessionId, 'JoinGroup', { GroupId: id, PlayingItemId: playingItemId }); } - }, emptyCallback); + }).catch((error) => { + console.error('Syncplay: unexpected error listing groups:', error); + }); loading.hide(); }); @@ -110,7 +100,7 @@ function showNewJoinGroupSelection (button, user, apiClient) { console.error(error); loading.hide(); toast({ - text: globalize.translate('MessageSyncplayNoGroupsAvailable') + text: globalize.translate('MessageSyncplayErrorAccessingGroups') }); }); } @@ -126,17 +116,16 @@ function showLeaveGroupSelection (button, user, apiClient) { if (!sessionId) { syncplayManager.signalError(); toast({ - // TODO: translate - text: "Syncplay error occured." + text: globalize.translate('MessageSyncplayErrorNoActivePlayer') }); + showNewJoinGroupSelection(button, user, apiClient); return; } - const menuItems = [{ name: globalize.translate('LabelSyncplayLeaveGroup'), - icon: "meeting_room", - id: "leave-group", + icon: 'meeting_room', + id: 'leave-group', selected: true, secondaryText: globalize.translate('LabelSyncplayLeaveGroupDescription') }]; @@ -150,17 +139,19 @@ function showLeaveGroupSelection (button, user, apiClient) { }; actionsheet.show(menuOptions).then(function (id) { - if (id == "leave-group") { - apiClient.sendSyncplayCommand(sessionId, "LeaveGroup"); + if (id == 'leave-group') { + apiClient.sendSyncplayCommand(sessionId, 'LeaveGroup'); } - }, emptyCallback); + }).catch((error) => { + console.error('Syncplay: unexpected error showing group menu:', error); + }); loading.hide(); } // Register to Syncplay events let syncplayEnabled = false; -events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) { +events.on(syncplayManager, 'enabled', function (e, enabled) { syncplayEnabled = enabled; }); @@ -173,11 +164,11 @@ export function show (button) { // TODO: should feature be disabled if playback permission is missing? playbackPermissionManager.check().then(() => { - console.debug("Playback is allowed."); + console.debug('Playback is allowed.'); }).catch((error) => { - console.error("Playback not allowed!", error); + console.error('Playback not allowed!', error); toast({ - text: globalize.translate("MessageSyncplayPlaybackPermissionRequired") + text: globalize.translate('MessageSyncplayPlaybackPermissionRequired') }); }); diff --git a/src/components/syncplay/playbackPermissionManager.js b/src/components/syncplay/playbackPermissionManager.js index df16545b39..3c258ad18d 100644 --- a/src/components/syncplay/playbackPermissionManager.js +++ b/src/components/syncplay/playbackPermissionManager.js @@ -11,7 +11,7 @@ function createTestMediaElement () { document.body.appendChild(elem); elem.volume = 1; // Volume should not be zero to trigger proper permissions - elem.src = "assets/audio/silence.wav"; // Silent sound + elem.src = 'assets/audio/silence.mp3'; // Silent sound return elem; } @@ -30,7 +30,7 @@ function destroyTestMediaElement (elem) { */ class PlaybackPermissionManager { /** - * Tests playback permission. Grabs the permission when called inside a click event (or any other valid user interaction). + * Tests playback permission. Grabs the permission when called inside a click event (or any other valid user interaction). * @returns {Promise} Promise that resolves succesfully if playback permission is allowed. */ check () { diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncplayManager.js index 0de232108b..b5694c88ff 100644 --- a/src/components/syncplay/syncplayManager.js +++ b/src/components/syncplay/syncplayManager.js @@ -1,5 +1,3 @@ -/* eslint-disable indent */ - /** * Module that manages the Syncplay feature. * @module components/syncplay/syncplayManager @@ -13,15 +11,25 @@ import toast from 'toast'; import globalize from 'globalize'; /** - * Waits for an event to be triggered on an object. + * 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 in milliseconds before rejecting promise if event does not trigger. * @returns {Promise} A promise that resolves when the event is triggered. */ -function waitForEvent(emitter, eventType) { - return new Promise((resolve) => { - var callback = () => { +function waitForEventOnce(emitter, eventType, timeout) { + return new Promise((resolve, reject) => { + let rejectTimeout; + if (timeout) { + rejectTimeout = setTimeout(() => { + reject('Timed out.'); + }, timeout); + } + const callback = () => { events.off(emitter, eventType, callback); + if (rejectTimeout) { + clearTimeout(rejectTimeout); + } resolve(arguments); }; events.on(emitter, eventType, callback); @@ -33,7 +41,7 @@ function waitForEvent(emitter, eventType) { * @returns {string} The player's id. */ function getActivePlayerId() { - var info = playbackManager.getPlayerInfo(); + var info = playbackManager.getPlayerInfo(); return info ? info.id : null; } @@ -48,11 +56,10 @@ const MaxAttemptsSpeedToSync = 3; // attempts before disabling SpeedToSync const MaxAttemptsSync = 5; // attempts before disabling syncing at all /** - * Time estimation + * Other constants */ -const PingIntervalTimeoutGreedy = 1000; // milliseconds -const PingIntervalTimeoutLowProfile = 60000; // milliseconds -const GreedyPingCount = 3; +const WaitForEventDefaultTimeout = 30000; // milliseconds +const WaitForPlayerEventTimeout = 500; // milliseconds /** * Class that manages the Syncplay feature. @@ -62,7 +69,7 @@ class SyncplayManager { this.playbackRateSupported = false; this.syncEnabled = false; 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 @@ -71,6 +78,7 @@ class SyncplayManager { this.minBufferingThresholdMillis = 1000; this.currentPlayer = null; + this.localPlayerPlaybackRate = 1.0; // used to restore user PlaybackRate this.syncplayEnabledAt = null; // Server time of when Syncplay has been enabled this.syncplayReady = false; // Syncplay is ready after first ping to server @@ -84,24 +92,37 @@ class SyncplayManager { this.timeOffsetWithServer = 0; // server time minus local time this.roundTripDuration = 0; this.notifySyncplayReady = false; - - events.on(playbackManager, "playerchange", () => { + + events.on(playbackManager, 'playbackstart', (player, state) => { + this.onPlaybackStart(player, state); + }); + + events.on(playbackManager, 'playbackstop', (stopInfo) => { + this.onPlaybackStop(stopInfo); + }); + + events.on(playbackManager, 'playerchange', () => { this.onPlayerChange(); }); this.bindToPlayer(playbackManager.getCurrentPlayer()); - events.on(this, "TimeUpdate", (event) => { + events.on(this, 'timeupdate', (event) => { this.syncPlaybackTime(); }); - events.on(timeSyncManager, "Update", (event, timeOffset, ping) => { + events.on(timeSyncManager, 'update', (event, error, timeOffset, ping) => { + if (error) { + console.debug('Syncplay, time update issue', error); + return; + } + this.timeOffsetWithServer = timeOffset; this.roundTripDuration = ping * 2; if (this.notifySyncplayReady) { this.syncplayReady = true; - events.trigger(this, "SyncplayReady"); + events.trigger(this, 'ready'); this.notifySyncplayReady = false; } @@ -113,33 +134,55 @@ class SyncplayManager { if (!sessionId) { this.signalError(); toast({ - // TODO: translate - text: "Syncplay error occured." + text: globalize.translate('MessageSyncplayErrorMissingSession') }); return; } - apiClient.sendSyncplayCommand(sessionId, "UpdatePing", { + apiClient.sendSyncplayCommand(sessionId, 'UpdatePing', { Ping: ping }); } }); } + /** + * Called when playback starts. + */ + onPlaybackStart (player, state) { + events.trigger(this, 'playbackstart', [player, state]); + } + + /** + * Called when playback stops. + */ + onPlaybackStop (stopInfo) { + events.trigger(this, 'playbackstop', [stopInfo]); + if (this.isSyncplayEnabled()) { + this.disableSyncplay(false); + } + } + /** * Called when the player changes. */ onPlayerChange () { this.bindToPlayer(playbackManager.getCurrentPlayer()); - events.trigger(this, "PlayerChange", [this.currentPlayer]); + events.trigger(this, 'playerchange', [this.currentPlayer]); } /** - * Called on playback state changes. - * @param {Object} e The playback state change event. + * Called when playback unpauses. */ - onPlayPauseStateChanged (e) { - events.trigger(this, "PlayPauseStateChange", [this.currentPlayer]); + onPlayerUnpause () { + events.trigger(this, 'unpause', [this.currentPlayer]); + } + + /** + * Called when playback pauses. + */ + onPlayerPause() { + events.trigger(this, 'pause', [this.currentPlayer]); } /** @@ -149,7 +192,7 @@ class SyncplayManager { 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]); } /** @@ -158,7 +201,7 @@ class SyncplayManager { onPlaying () { // TODO: implement group wait this.lastPlaybackWaiting = null; - events.trigger(this, "PlayerPlaying"); + events.trigger(this, 'playing'); } /** @@ -169,7 +212,7 @@ class SyncplayManager { if (!this.lastPlaybackWaiting) { this.lastPlaybackWaiting = new Date(); } - events.trigger(this, "PlayerWaiting"); + events.trigger(this, 'waiting'); } /** @@ -191,15 +234,18 @@ class SyncplayManager { this.currentPlayer = player; if (!player) return; } - - // TODO: remove this extra functions + + // FIXME: the following are needed because the 'events' module + // is changing the scope when executing the callbacks. + // For instance, calling 'onPlayerUnpause' from the wrong scope breaks things because 'this' + // points to 'player' (the event emitter) instead of pointing to the SyncplayManager singleton. const self = this; - this._onPlayPauseStateChanged = () => { - self.onPlayPauseStateChanged(); + this._onPlayerUnpause = () => { + self.onPlayerUnpause(); }; - this._onPlayPauseStateChanged = (e) => { - self.onPlayPauseStateChanged(e); + this._onPlayerPause = () => { + self.onPlayerPause(); }; this._onTimeUpdate = (e) => { @@ -214,12 +260,17 @@ class SyncplayManager { 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"); + events.on(player, 'unpause', this._onPlayerUnpause); + events.on(player, 'pause', this._onPlayerPause); + events.on(player, 'timeupdate', this._onTimeUpdate); + events.on(player, 'playing', this._onPlaying); + events.on(player, 'waiting', this._onWaiting); + this.playbackRateSupported = player.supports('PlaybackRate'); + + // Save player current PlaybackRate value + if (this.playbackRateSupported) { + this.localPlayerPlaybackRate = player.getPlaybackRate(); + } } /** @@ -228,13 +279,15 @@ class SyncplayManager { 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); + events.off(player, 'unpause', this._onPlayerUnpause); + events.off(player, 'pause', this._onPlayerPause); + events.off(player, 'timeupdate', this._onTimeUpdate); + events.off(player, 'playing', this._onPlaying); + events.off(player, 'waiting', this._onWaiting); + // Restore player original PlaybackRate value if (this.playbackRateSupported) { - player.setPlaybackRate(1); + player.setPlaybackRate(this.localPlayerPlaybackRate); + this.localPlayerPlaybackRate = 1.0; } this.currentPlayer = null; this.playbackRateSupported = false; @@ -262,8 +315,7 @@ class SyncplayManager { }); break; case 'GroupJoined': - const enabledAt = new Date(cmd.Data); - this.enableSyncplay(apiClient, enabledAt, true); + this.enableSyncplay(apiClient, new Date(cmd.Data), true); break; case 'NotInGroup': case 'GroupLeft': @@ -274,28 +326,28 @@ class SyncplayManager { text: globalize.translate('MessageSyncplayGroupWait', cmd.Data) }); break; - case 'GroupNotJoined': + case 'GroupDoesNotExist': toast({ - text: globalize.translate('MessageSyncplayGroupNotJoined', cmd.Data) + text: globalize.translate('MessageSyncplayGroupDoesNotExist') }); break; case 'CreateGroupDenied': toast({ - text: globalize.translate('MessageSyncplayCreateGroupDenied', cmd.Data) + text: globalize.translate('MessageSyncplayCreateGroupDenied') }); break; case 'JoinGroupDenied': toast({ - text: globalize.translate('MessageSyncplayJoinGroupDenied', cmd.Data) + text: globalize.translate('MessageSyncplayJoinGroupDenied') }); break; case 'LibraryAccessDenied': toast({ - text: globalize.translate('MessageSyncplayLibraryAccessDenied', cmd.Data) + text: globalize.translate('MessageSyncplayLibraryAccessDenied') }); break; default: - console.error('processSyncplayGroupUpdate does not recognize: ' + cmd.Type); + console.error('processSyncplayGroupUpdate: command is not recognised: ' + cmd.Type); break; } } @@ -309,12 +361,12 @@ class SyncplayManager { if (cmd === null) return; if (!this.isSyncplayEnabled()) { - console.debug("Syncplay processCommand: ignoring command", cmd); + console.debug('Syncplay processCommand: SyncPlay not enabled, ignoring command', cmd); return; } if (!this.syncplayReady) { - console.debug("Syncplay processCommand: queued command", cmd); + console.debug('Syncplay processCommand: SyncPlay not ready, queued command', cmd); this.queuedCommand = cmd; return; } @@ -323,7 +375,7 @@ class SyncplayManager { cmd.EmittedAt = new Date(cmd.EmitttedAt); if (cmd.EmitttedAt < this.syncplayEnabledAt) { - console.debug("Syncplay processCommand: ignoring old command", cmd); + console.debug('Syncplay processCommand: ignoring old command', cmd); return; } @@ -333,12 +385,12 @@ class SyncplayManager { this.lastCommand.PositionTicks === cmd.PositionTicks && this.Command === cmd.Command ) { - console.debug("Syncplay processCommand: ignoring duplicate command", cmd); + console.debug('Syncplay processCommand: ignoring duplicate command', cmd); return; } this.lastCommand = cmd; - console.log("Syncplay will", cmd.Command, "at", cmd.When, "PositionTicks", cmd.PositionTicks); + console.log('Syncplay will', cmd.Command, 'at', cmd.When, 'PositionTicks', cmd.PositionTicks); switch (cmd.Command) { case 'Play': @@ -351,7 +403,7 @@ class SyncplayManager { this.scheduleSeek(cmd.When, cmd.PositionTicks); break; default: - console.error('processSyncplayCommand does not recognize: ' + cmd.Type); + console.error('processCommand: command is not recognised: ' + cmd.Type); break; } } @@ -363,7 +415,7 @@ class SyncplayManager { * @param {Object} sessionData Info about the content to load. */ prepareSession (apiClient, groupId, sessionData) { - var serverId = apiClient.serverInfo().Id; + const serverId = apiClient.serverInfo().Id; playbackManager.play({ ids: sessionData.ItemIds, startPositionTicks: sessionData.StartPositionTicks, @@ -373,14 +425,12 @@ class SyncplayManager { startIndex: sessionData.StartIndex, serverId: serverId }).then(() => { - waitForEvent(this, "PlayerChange").then(() => { - playbackManager.pause(); + waitForEventOnce(this, 'playbackstart', WaitForEventDefaultTimeout).then(() => { var sessionId = getActivePlayerId(); if (!sessionId) { - console.error("Missing sessionId!"); + console.error('Missing sessionId!'); toast({ - // TODO: translate - text: "Failed to enable Syncplay! Missing session id." + text: globalize.translate('MessageSyncplayErrorMissingSession') }); return; } @@ -390,21 +440,38 @@ class SyncplayManager { const playState = playbackManager.getPlayerState(); playingItemId = playState.NowPlayingItem.Id; } catch (error) { - playingItemId = ""; + playingItemId = ''; } - // Sometimes JoinGroup fails, maybe because server hasn't been updated yet - setTimeout(() => { - apiClient.sendSyncplayCommand(sessionId, "JoinGroup", { + // Make sure the server has received the player state + waitForEventOnce(playbackManager, 'reportplayback', WaitForEventDefaultTimeout).then((success) => { + this.localPause(); + if (!success) { + console.warning('Error reporting playback state to server. Joining group will fail.'); + } + apiClient.sendSyncplayCommand(sessionId, 'JoinGroup', { GroupId: groupId, PlayingItemId: playingItemId }); - }, 500); + }).catch(() => { + console.error('Timed out while waiting for `reportplayback` event!'); + toast({ + text: globalize.translate('MessageSyncplayErrorMedia') + }); + return; + }); + }).catch(() => { + console.error('Timed out while waiting for `playbackstart` event!'); + if (!this.isSyncplayEnabled()) { + toast({ + text: globalize.translate('MessageSyncplayErrorMedia') + }); + } + return; }); }).catch((error) => { console.error(error); toast({ - // TODO: translate - text: "Failed to enable Syncplay! Media error." + text: globalize.translate('MessageSyncplayErrorMedia') }); }); } @@ -418,12 +485,13 @@ class SyncplayManager { enableSyncplay (apiClient, enabledAt, showMessage = false) { this.syncplayEnabledAt = enabledAt; this.injectPlaybackManager(); - events.trigger(this, "SyncplayEnabled", [true]); + events.trigger(this, 'enabled', [true]); - waitForEvent(this, "SyncplayReady").then(() => { + waitForEventOnce(this, 'ready').then(() => { this.processCommand(this.queuedCommand, apiClient); this.queuedCommand = null; }); + this.syncplayReady = false; this.notifySyncplayReady = true; @@ -446,7 +514,7 @@ class SyncplayManager { this.lastCommand = null; this.queuedCommand = null; this.syncEnabled = false; - events.trigger(this, "SyncplayEnabled", [false]); + events.trigger(this, 'enabled', [false]); this.restorePlaybackManager(); if (showMessage) { @@ -461,7 +529,7 @@ class SyncplayManager { * @returns {boolean} _true_ if user joined a group, _false_ otherwise. */ isSyncplayEnabled () { - return this.syncplayEnabledAt !== null ? true : false; + return this.syncplayEnabledAt !== null; } /** @@ -471,15 +539,15 @@ class SyncplayManager { */ schedulePlay (playAtTime, positionTicks) { this.clearScheduledCommand(); - var currentTime = new Date(); - var playAtTimeLocal = timeSyncManager.serverDateToLocal(playAtTime); + const currentTime = new Date(); + const playAtTimeLocal = timeSyncManager.serverDateToLocal(playAtTime); if (playAtTimeLocal > currentTime) { - var playTimeout = playAtTimeLocal - currentTime; - playbackManager.syncplay_seek(positionTicks); + const playTimeout = playAtTimeLocal - currentTime; + this.localSeek(positionTicks); this.scheduledCommand = setTimeout(() => { - playbackManager.syncplay_unpause(); + this.localUnpause(); this.syncTimeout = setTimeout(() => { this.syncEnabled = true; @@ -487,12 +555,14 @@ class SyncplayManager { }, playTimeout); - // console.debug("Syncplay schedulePlay:", playTimeout); + console.debug('Scheduled play in', playTimeout / 1000.0, 'seconds.'); } else { // Group playback already started - var serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000; - playbackManager.syncplay_unpause(); - playbackManager.syncplay_seek(serverPositionTicks); + const serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000; + waitForEventOnce(this, 'unpause').then(() => { + this.localSeek(serverPositionTicks); + }); + this.localUnpause(); this.syncTimeout = setTimeout(() => { this.syncEnabled = true; @@ -507,24 +577,26 @@ class SyncplayManager { */ schedulePause (pauseAtTime, positionTicks) { this.clearScheduledCommand(); - var currentTime = new Date(); - var pauseAtTimeLocal = timeSyncManager.serverDateToLocal(pauseAtTime); + const currentTime = new Date(); + const pauseAtTimeLocal = timeSyncManager.serverDateToLocal(pauseAtTime); + + const callback = () => { + waitForEventOnce(this, 'pause', WaitForPlayerEventTimeout).then(() => { + this.localSeek(positionTicks); + }).catch(() => { + // Player was already paused, seeking + this.localSeek(positionTicks); + }); + this.localPause(); + }; if (pauseAtTimeLocal > currentTime) { - var pauseTimeout = pauseAtTimeLocal - currentTime; + const pauseTimeout = pauseAtTimeLocal - currentTime; + this.scheduledCommand = setTimeout(callback, pauseTimeout); - this.scheduledCommand = setTimeout(() => { - playbackManager.syncplay_pause(); - setTimeout(() => { - playbackManager.syncplay_seek(positionTicks); - }, 800); - - }, pauseTimeout); + console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.'); } else { - playbackManager.syncplay_pause(); - setTimeout(() => { - playbackManager.syncplay_seek(positionTicks); - }, 800); + callback(); } } @@ -558,10 +630,10 @@ class SyncplayManager { 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; + // TODO: make this less hacky + playbackManager._localUnpause = playbackManager.unpause; + playbackManager._localPause = playbackManager.pause; + playbackManager._localSeek = playbackManager.seek; playbackManager.unpause = this.playRequest; playbackManager.pause = this.pauseRequest; @@ -576,9 +648,9 @@ class SyncplayManager { if (this.isSyncplayEnabled()) return; if (!playbackManager.syncplayEnabled) return; - playbackManager.unpause = playbackManager.syncplay_unpause; - playbackManager.pause = playbackManager.syncplay_pause; - playbackManager.seek = playbackManager.syncplay_seek; + playbackManager.unpause = playbackManager._localUnpause; + playbackManager.pause = playbackManager._localPause; + playbackManager.seek = playbackManager._localSeek; playbackManager.syncplayEnabled = false; } @@ -588,7 +660,7 @@ class SyncplayManager { playRequest (player) { var apiClient = connectionManager.currentApiClient(); var sessionId = getActivePlayerId(); - apiClient.sendSyncplayCommand(sessionId, "PlayRequest"); + apiClient.sendSyncplayCommand(sessionId, 'PlayRequest'); } /** @@ -597,9 +669,9 @@ class SyncplayManager { pauseRequest (player) { var apiClient = connectionManager.currentApiClient(); var sessionId = getActivePlayerId(); - apiClient.sendSyncplayCommand(sessionId, "PauseRequest"); + apiClient.sendSyncplayCommand(sessionId, 'PauseRequest'); // Pause locally as well, to give the user some little control - playbackManager.syncplay_pause(); + playbackManager._localUnpause(player); } /** @@ -608,14 +680,47 @@ class SyncplayManager { seekRequest (PositionTicks, player) { var apiClient = connectionManager.currentApiClient(); var sessionId = getActivePlayerId(); - apiClient.sendSyncplayCommand(sessionId, "SeekRequest", { + apiClient.sendSyncplayCommand(sessionId, 'SeekRequest', { PositionTicks: PositionTicks }); } + /** + * Calls original PlaybackManager's unpause method. + */ + localUnpause(player) { + if (playbackManager.syncplayEnabled) { + playbackManager._localUnpause(player); + } else { + playbackManager.unpause(player); + } + } + + /** + * Calls original PlaybackManager's pause method. + */ + localPause(player) { + if (playbackManager.syncplayEnabled) { + playbackManager._localPause(player); + } else { + playbackManager.pause(player); + } + } + + /** + * Calls original PlaybackManager's seek method. + */ + localSeek(PositionTicks, player) { + if (playbackManager.syncplayEnabled) { + playbackManager._localSeek(PositionTicks, player); + } else { + playbackManager.seek(PositionTicks, player); + } + } + /** * 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. @@ -637,18 +742,15 @@ class SyncplayManager { const playAtTime = this.lastCommand.When; - const CurrentPositionTicks = playbackManager.currentTime(); + const currentPositionTicks = playbackManager.currentTime(); // Estimate PositionTicks on server - const ServerPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) + this.timeOffsetWithServer) * 10000; + const serverPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) + this.timeOffsetWithServer) * 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; + const diffMillis = (serverPositionTicks - currentPositionTicks) / 10000.0; this.playbackDiffMillis = diffMillis; - // console.debug("Syncplay onTimeUpdate", diffMillis, CurrentPositionTicks, ServerPositionTicks); - if (this.syncEnabled) { const absDiffMillis = Math.abs(diffMillis); // TODO: SpeedToSync sounds bad on songs @@ -664,7 +766,7 @@ class SyncplayManager { this.currentPlayer.setPlaybackRate(speed); this.syncEnabled = false; this.syncAttempts++; - this.showSyncIcon("SpeedToSync (x" + speed + ")"); + this.showSyncIcon('SpeedToSync (x' + speed + ')'); this.syncTimeout = setTimeout(() => { this.currentPlayer.setPlaybackRate(1); @@ -675,13 +777,13 @@ class SyncplayManager { // Disable SkipToSync if it keeps failing if (this.syncAttempts > MaxAttemptsSync) { this.syncEnabled = false; - this.showSyncIcon("Sync disabled (too many attempts)"); + this.showSyncIcon('Sync disabled (too many attempts)'); } // SkipToSync method - playbackManager.syncplay_seek(ServerPositionTicks); + this.localSeek(serverPositionTicks); this.syncEnabled = false; this.syncAttempts++; - this.showSyncIcon("SkipToSync (" + this.syncAttempts + ")"); + this.showSyncIcon('SkipToSync (' + this.syncAttempts + ')'); this.syncTimeout = setTimeout(() => { this.syncEnabled = true; @@ -690,7 +792,7 @@ class SyncplayManager { } else { // Playback is synced if (this.syncAttempts > 0) { - // console.debug("Playback has been synced after", this.syncAttempts, "attempts."); + console.debug('Playback has been synced after', this.syncAttempts, 'attempts.'); } this.syncAttempts = 0; } @@ -706,7 +808,7 @@ class SyncplayManager { TimeOffset: this.timeOffsetWithServer, PlaybackDiff: this.playbackDiffMillis, SyncMethod: this.syncMethod - } + }; } /** @@ -714,15 +816,15 @@ class SyncplayManager { */ showSyncIcon (syncMethod) { this.syncMethod = syncMethod; - events.trigger(this, "SyncplayError", [true]); + events.trigger(this, 'syncing', [true, this.syncMethod]); } /** * Emits an event to clear the Syncplay status icon. */ clearSyncIcon () { - this.syncMethod = "None"; - events.trigger(this, "SyncplayError", [false]); + this.syncMethod = 'None'; + events.trigger(this, 'syncing', [false, this.syncMethod]); } /** diff --git a/src/components/syncplay/timeSyncManager.js b/src/components/syncplay/timeSyncManager.js index 74c98820c2..ca92939576 100644 --- a/src/components/syncplay/timeSyncManager.js +++ b/src/components/syncplay/timeSyncManager.js @@ -1,5 +1,3 @@ -/* eslint-disable indent */ - /** * Module that manages time syncing with server. * @module components/syncplay/timeSyncManager @@ -22,30 +20,30 @@ const GreedyPingCount = 3; 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 + * @param {Date} requestSent Client's timestamp of the request transmission + * @param {Date} requestReceived Server's timestamp of the request reception + * @param {Date} responseSent Server's timestamp of the response transmission + * @param {Date} responseReceived 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(); + constructor(requestSent, requestReceived, responseSent, responseReceived) { + this.requestSent = requestSent.getTime(); + this.requestReceived = requestReceived.getTime(); + this.responseSent = responseSent.getTime(); + this.responseReceived = responseReceived.getTime(); } /** * Time offset from server. */ getOffset () { - return ((this.t1 - this.t0) + (this.t2 - this.t3)) / 2; + return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2; } /** * Get round-trip delay. */ getDelay () { - return (this.t3 - this.t0) - (this.t2 - this.t1); + return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived); } /** @@ -76,7 +74,7 @@ class TimeSyncManager { * @returns {boolean} _true_ if a measurement has been done, _false_ otherwise. */ isReady() { - return this.measurement ? true : false; + return !!this.measurement; } /** @@ -119,14 +117,14 @@ class TimeSyncManager { this.poller = setTimeout(() => { this.poller = null; const apiClient = connectionManager.currentApiClient(); - const t0 = new Date(); // pingStartTime + const requestSent = new Date(); apiClient.getServerTime().then((response) => { - const t3 = new Date(); // pingEndTime + const responseReceived = new Date(); response.json().then((data) => { - const t1 = new Date(data.RequestReceptionTime); // request received - const t2 = new Date(data.ResponseTransmissionTime); // response sent + const requestReceived = new Date(data.RequestReceptionTime); + const responseSent = new Date(data.ResponseTransmissionTime); - const measurement = new Measurement(t0, t1, t2, t3); + const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived); this.updateTimeOffset(measurement); // Avoid overloading server @@ -136,11 +134,11 @@ class TimeSyncManager { this.pings++; } - events.trigger(this, "Update", [this.getTimeOffset(), this.getPing()]); + events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]); }); }).catch((error) => { console.error(error); - events.trigger(this, "Error", [error]); + events.trigger(this, 'update', [error, null, null]); }).finally(() => { this.requestPing(); }); diff --git a/src/scripts/librarymenu.js b/src/scripts/librarymenu.js index 1daa200b8b..3113031ea8 100644 --- a/src/scripts/librarymenu.js +++ b/src/scripts/librarymenu.js @@ -1,4 +1,4 @@ -define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'syncplayManager', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, syncplayManager, browser, globalize, imageHelper) { +define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'syncplayManager', 'groupSelectionMenu', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, syncplayManager, groupSelectionMenu, browser, globalize, imageHelper) { 'use strict'; function renderHeader() { @@ -89,12 +89,13 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' var policy = user.Policy ? user.Policy : user.localUser.Policy; - if (headerSyncButton && policy && policy.SyncplayAccess !== "None") { - headerSyncButton.classList.remove("hide"); + if (headerSyncButton && policy && policy.SyncplayAccess !== 'None') { + headerSyncButton.classList.remove('hide'); } } else { headerHomeButton.classList.add('hide'); headerCastButton.classList.add('hide'); + headerSyncButton.classList.add('hide'); if (headerSearchButton) { headerSearchButton.classList.add('hide'); @@ -188,27 +189,26 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' function onSyncButtonClicked() { var btn = this; - - require(["groupSelectionMenu"], function (groupSelectionMenu) { - groupSelectionMenu.show(btn); - }); + groupSelectionMenu.show(btn); } - function updateSyncplayIcon(event, enabled) { - var icon = headerSyncButton.querySelector("i"); + function onSyncplayEnabled(event, enabled) { + var icon = headerSyncButton.querySelector('span'); + icon.classList.remove('sync', 'sync_disabled', 'sync_problem'); if (enabled) { - icon.innerHTML = "sync"; + icon.classList.add('sync'); } else { - icon.innerHTML = "sync_disabled"; + icon.classList.add('sync_disabled'); } } - function updateSyncplayErrorIcon(event, show_error) { - var icon = headerSyncButton.querySelector("i"); - if (show_error) { - icon.innerHTML = "sync_problem"; + function onSyncplaySyncing(event, is_syncing, syncMethod) { + var icon = headerSyncButton.querySelector('span'); + icon.classList.remove('sync', 'sync_disabled', 'sync_problem'); + if (is_syncing) { + icon.classList.add('sync_problem'); } else { - icon.innerHTML = "sync"; + icon.classList.add('sync'); } } @@ -967,8 +967,8 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' updateUserInHeader(); }); events.on(playbackManager, 'playerchange', updateCastIcon); - events.on(syncplayManager, 'SyncplayEnabled', updateSyncplayIcon); - events.on(syncplayManager, 'SyncplayError', updateSyncplayErrorIcon); + events.on(syncplayManager, 'enabled', onSyncplayEnabled); + events.on(syncplayManager, 'syncing', onSyncplaySyncing); loadNavDrawer(); return LibraryMenu; }); diff --git a/src/scripts/site.js b/src/scripts/site.js index ecfeb27349..a07062ab3c 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -316,7 +316,7 @@ var AppInfo = {}; function returnDefault(obj) { if (obj.default === null) { - throw new Error("Object has no default!"); + throw new Error('Object has no default!'); } return obj.default; } @@ -825,7 +825,8 @@ var AppInfo = {}; define('homescreenSettings', [componentsPath + '/homescreensettings/homescreensettings'], returnFirstDependency); define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager); define('timeSyncManager', [componentsPath + '/syncplay/timeSyncManager'], returnDefault); - define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnDefault); + define('groupSelectionMenu', [componentsPath + '/syncplay/groupSelectionMenu'], returnFirstDependency); + define('syncplayManager', [componentsPath + '/syncplay/syncplayManager'], returnDefault); define('playbackPermissionManager', [componentsPath + '/syncplay/playbackPermissionManager'], returnDefault); define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager); define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index fb72e8881c..4759b670d8 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -855,7 +855,8 @@ "LabelSubtitlePlaybackMode": "Subtitle mode:", "LabelSubtitles": "Subtitles", "LabelSupportedMediaTypes": "Supported Media Types:", - "LabelSyncplayTimeOffset": "Time offset with server:", + "LabelSyncplayTimeOffset": "Time offset with the server:", + "MillisecondsUnit": "ms", "LabelSyncplayPlaybackDiff": "Playback time difference:", "LabelSyncplaySyncMethod": "Sync method:", "LabelSyncplayNewGroup": "New group", @@ -1031,15 +1032,19 @@ "MessageYouHaveVersionInstalled": "You currently have version {0} installed.", "MessageSyncplayEnabled": "Syncplay enabled.", "MessageSyncplayDisabled": "Syncplay disabled.", - "MessageSyncplayUserJoined": "{0} joined group.", - "MessageSyncplayUserLeft": "{0} left group.", + "MessageSyncplayUserJoined": "{0} has joined the group.", + "MessageSyncplayUserLeft": "{0} has left the group.", "MessageSyncplayGroupWait": "{0} is buffering...", "MessageSyncplayNoGroupsAvailable": "No groups available. Start playing something first.", "MessageSyncplayPlaybackPermissionRequired": "Playback permission required.", - "MessageSyncplayGroupNotJoined": "Failed to join requested group.", + "MessageSyncplayGroupDoesNotExist": "Failed to join group because it does not exist.", "MessageSyncplayCreateGroupDenied": "Permission required to create a group.", "MessageSyncplayJoinGroupDenied": "Permission required to use Syncplay.", "MessageSyncplayLibraryAccessDenied": "Access to this content is restricted.", + "MessageSyncplayErrorAccessingGroups": "An error occurred while accessing groups list.", + "MessageSyncplayErrorNoActivePlayer": "No active player found. Syncplay has been disabled.", + "MessageSyncplayErrorMissingSession": "Failed to enable Syncplay! Missing session.", + "MessageSyncplayErrorMedia": "Failed to enable Syncplay! Media error.", "Metadata": "Metadata", "MetadataManager": "Metadata Manager", "MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.", diff --git a/webpack.dev.js b/webpack.dev.js index d8879fe808..b869147750 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -46,8 +46,8 @@ module.exports = merge(common, { ] }, { - test: /\.(wav)$/i, - use: ["file-loader"] + test: /\.(mp3)$/i, + use: ['file-loader'] } ] } diff --git a/webpack.prod.js b/webpack.prod.js index cc4c57b9f4..2f5315ea75 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -39,8 +39,8 @@ module.exports = merge(common, { ] }, { - test: /\.(wav)$/i, - use: ["file-loader"] + test: /\.(mp3)$/i, + use: ['file-loader'] } ] } From 36d097291ee9ab1cfb841d2a5752e7f9026a7798 Mon Sep 17 00:00:00 2001 From: gion Date: Wed, 6 May 2020 23:41:54 +0200 Subject: [PATCH 13/14] Rename Syncplay to SyncPlay --- package.json | 2 +- src/components/playerstats/playerstats.js | 18 +-- src/components/serverNotifications.js | 10 +- src/components/syncplay/groupSelectionMenu.js | 56 +++---- ...{syncplayManager.js => syncPlayManager.js} | 150 +++++++++--------- src/controllers/useredit.js | 4 +- src/scripts/librarymenu.js | 12 +- src/scripts/site.js | 2 +- src/strings/en-us.json | 58 +++---- src/useredit.html | 12 +- 10 files changed, 162 insertions(+), 162 deletions(-) rename src/components/syncplay/{syncplayManager.js => syncPlayManager.js} (86%) diff --git a/package.json b/package.json index 33a7d2b004..1beb164990 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "src/components/syncplay/playbackPermissionManager.js", "src/components/syncplay/groupSelectionMenu.js", "src/components/syncplay/timeSyncManager.js", - "src/components/syncplay/syncplayManager.js", + "src/components/syncplay/syncPlayManager.js", "src/scripts/dfnshelper.js", "src/scripts/dom.js", "src/scripts/filesystem.js", diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index 07fcd7070a..a65baf3553 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -1,4 +1,4 @@ -define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplayManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, syncplayManager, playMethodHelper, layoutManager, serverNotifications) { +define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncPlayManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, syncPlayManager, playMethodHelper, layoutManager, serverNotifications) { 'use strict'; function init(instance) { @@ -327,22 +327,22 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplay return sessionStats; } - function getSyncplayStats() { + function getSyncPlayStats() { var syncStats = []; - var stats = syncplayManager.getStats(); + var stats = syncPlayManager.getStats(); syncStats.push({ - label: globalize.translate('LabelSyncplayTimeOffset'), + label: globalize.translate('LabelSyncPlayTimeOffset'), value: stats.TimeOffset + globalize.translate('MillisecondsUnit') }); syncStats.push({ - label: globalize.translate('LabelSyncplayPlaybackDiff'), + label: globalize.translate('LabelSyncPlayPlaybackDiff'), value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit') }); syncStats.push({ - label: globalize.translate('LabelSyncplaySyncMethod'), + label: globalize.translate('LabelSyncPlaySyncMethod'), value: stats.SyncMethod }); @@ -405,10 +405,10 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplay name: 'Original Media Info' }); - if (syncplayManager.isSyncplayEnabled()) { + if (syncPlayManager.isSyncPlayEnabled()) { categories.push({ - stats: getSyncplayStats(), - name: 'Syncplay Info' + stats: getSyncPlayStats(), + name: 'SyncPlay Info' }); } diff --git a/src/components/serverNotifications.js b/src/components/serverNotifications.js index 876c3f7e79..2553c284f0 100644 --- a/src/components/serverNotifications.js +++ b/src/components/serverNotifications.js @@ -1,4 +1,4 @@ -define(['connectionManager', 'playbackManager', 'syncplayManager', 'events', 'inputManager', 'focusManager', 'appRouter'], function (connectionManager, playbackManager, syncplayManager, events, inputManager, focusManager, appRouter) { +define(['connectionManager', 'playbackManager', 'syncPlayManager', 'events', 'inputManager', 'focusManager', 'appRouter'], function (connectionManager, playbackManager, syncPlayManager, events, inputManager, focusManager, appRouter) { 'use strict'; var serverNotifications = {}; @@ -187,10 +187,10 @@ define(['connectionManager', 'playbackManager', 'syncplayManager', 'events', 'in events.trigger(serverNotifications, 'UserDataChanged', [apiClient, msg.Data.UserDataList[i]]); } } - } else if (msg.MessageType === 'SyncplayCommand') { - syncplayManager.processCommand(msg.Data, apiClient); - } else if (msg.MessageType === 'SyncplayGroupUpdate') { - syncplayManager.processGroupUpdate(msg.Data, apiClient); + } else if (msg.MessageType === 'SyncPlayCommand') { + syncPlayManager.processCommand(msg.Data, apiClient); + } else if (msg.MessageType === 'SyncPlayGroupUpdate') { + syncPlayManager.processGroupUpdate(msg.Data, apiClient); } else { events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]); } diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js index af08f92776..067100ad73 100644 --- a/src/components/syncplay/groupSelectionMenu.js +++ b/src/components/syncplay/groupSelectionMenu.js @@ -1,7 +1,7 @@ import events from 'events'; import connectionManager from 'connectionManager'; import playbackManager from 'playbackManager'; -import syncplayManager from 'syncplayManager'; +import syncPlayManager from 'syncPlayManager'; import loading from 'loading'; import toast from 'toast'; import actionsheet from 'actionsheet'; @@ -37,7 +37,7 @@ function showNewJoinGroupSelection (button, user, apiClient) { console.debug('No item is currently playing.'); } - apiClient.sendSyncplayCommand(sessionId, 'ListGroups').then(function (response) { + apiClient.sendSyncPlayCommand(sessionId, 'ListGroups').then(function (response) { response.json().then(function (groups) { var menuItems = groups.map(function (group) { return { @@ -49,24 +49,24 @@ function showNewJoinGroupSelection (button, user, apiClient) { }; }); - if (inSession && policy.SyncplayAccess === 'CreateAndJoinGroups') { + if (inSession && policy.SyncPlayAccess === 'CreateAndJoinGroups') { menuItems.push({ - name: globalize.translate('LabelSyncplayNewGroup'), + name: globalize.translate('LabelSyncPlayNewGroup'), icon: 'add', id: 'new-group', selected: true, - secondaryText: globalize.translate('LabelSyncplayNewGroupDescription') + secondaryText: globalize.translate('LabelSyncPlayNewGroupDescription') }); } if (menuItems.length === 0) { - if (inSession && policy.SyncplayAccess === 'JoinGroups') { + if (inSession && policy.SyncPlayAccess === 'JoinGroups') { toast({ - text: globalize.translate('MessageSyncplayCreateGroupDenied') + text: globalize.translate('MessageSyncPlayCreateGroupDenied') }); } else { toast({ - text: globalize.translate('MessageSyncplayNoGroupsAvailable') + text: globalize.translate('MessageSyncPlayNoGroupsAvailable') }); } loading.hide(); @@ -74,7 +74,7 @@ function showNewJoinGroupSelection (button, user, apiClient) { } var menuOptions = { - title: globalize.translate('HeaderSyncplaySelectGroup'), + title: globalize.translate('HeaderSyncPlaySelectGroup'), items: menuItems, positionTo: button, resolveOnClick: true, @@ -83,15 +83,15 @@ function showNewJoinGroupSelection (button, user, apiClient) { actionsheet.show(menuOptions).then(function (id) { if (id == 'new-group') { - apiClient.sendSyncplayCommand(sessionId, 'NewGroup'); + apiClient.sendSyncPlayCommand(sessionId, 'NewGroup'); } else { - apiClient.sendSyncplayCommand(sessionId, 'JoinGroup', { + apiClient.sendSyncPlayCommand(sessionId, 'JoinGroup', { GroupId: id, PlayingItemId: playingItemId }); } }).catch((error) => { - console.error('Syncplay: unexpected error listing groups:', error); + console.error('SyncPlay: unexpected error listing groups:', error); }); loading.hide(); @@ -100,7 +100,7 @@ function showNewJoinGroupSelection (button, user, apiClient) { console.error(error); loading.hide(); toast({ - text: globalize.translate('MessageSyncplayErrorAccessingGroups') + text: globalize.translate('MessageSyncPlayErrorAccessingGroups') }); }); } @@ -114,24 +114,24 @@ function showNewJoinGroupSelection (button, user, apiClient) { function showLeaveGroupSelection (button, user, apiClient) { const sessionId = getActivePlayerId(); if (!sessionId) { - syncplayManager.signalError(); + syncPlayManager.signalError(); toast({ - text: globalize.translate('MessageSyncplayErrorNoActivePlayer') + text: globalize.translate('MessageSyncPlayErrorNoActivePlayer') }); showNewJoinGroupSelection(button, user, apiClient); return; } const menuItems = [{ - name: globalize.translate('LabelSyncplayLeaveGroup'), + name: globalize.translate('LabelSyncPlayLeaveGroup'), icon: 'meeting_room', id: 'leave-group', selected: true, - secondaryText: globalize.translate('LabelSyncplayLeaveGroupDescription') + secondaryText: globalize.translate('LabelSyncPlayLeaveGroupDescription') }]; var menuOptions = { - title: globalize.translate('HeaderSyncplayEnabled'), + title: globalize.translate('HeaderSyncPlayEnabled'), items: menuItems, positionTo: button, resolveOnClick: true, @@ -140,23 +140,23 @@ function showLeaveGroupSelection (button, user, apiClient) { actionsheet.show(menuOptions).then(function (id) { if (id == 'leave-group') { - apiClient.sendSyncplayCommand(sessionId, 'LeaveGroup'); + apiClient.sendSyncPlayCommand(sessionId, 'LeaveGroup'); } }).catch((error) => { - console.error('Syncplay: unexpected error showing group menu:', error); + console.error('SyncPlay: unexpected error showing group menu:', error); }); loading.hide(); } -// Register to Syncplay events -let syncplayEnabled = false; -events.on(syncplayManager, 'enabled', function (e, enabled) { - syncplayEnabled = enabled; +// Register to SyncPlay events +let syncPlayEnabled = false; +events.on(syncPlayManager, 'enabled', function (e, enabled) { + syncPlayEnabled = enabled; }); /** - * Shows a menu to handle Syncplay groups. + * Shows a menu to handle SyncPlay groups. * @param {HTMLElement} button - Element where to place the menu. */ export function show (button) { @@ -168,13 +168,13 @@ export function show (button) { }).catch((error) => { console.error('Playback not allowed!', error); toast({ - text: globalize.translate('MessageSyncplayPlaybackPermissionRequired') + text: globalize.translate('MessageSyncPlayPlaybackPermissionRequired') }); }); const apiClient = connectionManager.currentApiClient(); connectionManager.user(apiClient).then((user) => { - if (syncplayEnabled) { + if (syncPlayEnabled) { showLeaveGroupSelection(button, user, apiClient); } else { showNewJoinGroupSelection(button, user, apiClient); @@ -183,7 +183,7 @@ export function show (button) { console.error(error); loading.hide(); toast({ - text: globalize.translate('MessageSyncplayNoGroupsAvailable') + text: globalize.translate('MessageSyncPlayNoGroupsAvailable') }); }); } diff --git a/src/components/syncplay/syncplayManager.js b/src/components/syncplay/syncPlayManager.js similarity index 86% rename from src/components/syncplay/syncplayManager.js rename to src/components/syncplay/syncPlayManager.js index b5694c88ff..f5c9ac446d 100644 --- a/src/components/syncplay/syncplayManager.js +++ b/src/components/syncplay/syncPlayManager.js @@ -1,6 +1,6 @@ /** - * Module that manages the Syncplay feature. - * @module components/syncplay/syncplayManager + * Module that manages the SyncPlay feature. + * @module components/syncplay/syncPlayManager */ import events from 'events'; @@ -62,9 +62,9 @@ const WaitForEventDefaultTimeout = 30000; // milliseconds const WaitForPlayerEventTimeout = 500; // milliseconds /** - * Class that manages the Syncplay feature. + * Class that manages the SyncPlay feature. */ -class SyncplayManager { +class SyncPlayManager { constructor() { this.playbackRateSupported = false; this.syncEnabled = false; @@ -80,8 +80,8 @@ class SyncplayManager { this.currentPlayer = null; this.localPlayerPlaybackRate = 1.0; // used to restore user PlaybackRate - this.syncplayEnabledAt = null; // Server time of when Syncplay has been enabled - this.syncplayReady = false; // Syncplay is ready after first ping to server + 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; @@ -91,7 +91,7 @@ class SyncplayManager { this.timeOffsetWithServer = 0; // server time minus local time this.roundTripDuration = 0; - this.notifySyncplayReady = false; + this.notifySyncPlayReady = false; events.on(playbackManager, 'playbackstart', (player, state) => { this.onPlaybackStart(player, state); @@ -113,17 +113,17 @@ class SyncplayManager { events.on(timeSyncManager, 'update', (event, error, timeOffset, ping) => { if (error) { - console.debug('Syncplay, time update issue', error); + console.debug('SyncPlay, time update issue', error); return; } this.timeOffsetWithServer = timeOffset; this.roundTripDuration = ping * 2; - if (this.notifySyncplayReady) { - this.syncplayReady = true; + if (this.notifySyncPlayReady) { + this.syncPlayReady = true; events.trigger(this, 'ready'); - this.notifySyncplayReady = false; + this.notifySyncPlayReady = false; } // Report ping @@ -134,12 +134,12 @@ class SyncplayManager { if (!sessionId) { this.signalError(); toast({ - text: globalize.translate('MessageSyncplayErrorMissingSession') + text: globalize.translate('MessageSyncPlayErrorMissingSession') }); return; } - apiClient.sendSyncplayCommand(sessionId, 'UpdatePing', { + apiClient.sendSyncPlayCommand(sessionId, 'UpdatePing', { Ping: ping }); } @@ -158,8 +158,8 @@ class SyncplayManager { */ onPlaybackStop (stopInfo) { events.trigger(this, 'playbackstop', [stopInfo]); - if (this.isSyncplayEnabled()) { - this.disableSyncplay(false); + if (this.isSyncPlayEnabled()) { + this.disableSyncPlay(false); } } @@ -238,7 +238,7 @@ class SyncplayManager { // FIXME: the following are needed because the 'events' module // is changing the scope when executing the callbacks. // For instance, calling 'onPlayerUnpause' from the wrong scope breaks things because 'this' - // points to 'player' (the event emitter) instead of pointing to the SyncplayManager singleton. + // points to 'player' (the event emitter) instead of pointing to the SyncPlayManager singleton. const self = this; this._onPlayerUnpause = () => { self.onPlayerUnpause(); @@ -306,48 +306,48 @@ class SyncplayManager { break; case 'UserJoined': toast({ - text: globalize.translate('MessageSyncplayUserJoined', cmd.Data) + text: globalize.translate('MessageSyncPlayUserJoined', cmd.Data) }); break; case 'UserLeft': toast({ - text: globalize.translate('MessageSyncplayUserLeft', cmd.Data) + text: globalize.translate('MessageSyncPlayUserLeft', cmd.Data) }); break; case 'GroupJoined': - this.enableSyncplay(apiClient, new Date(cmd.Data), true); + this.enableSyncPlay(apiClient, new Date(cmd.Data), true); break; case 'NotInGroup': case 'GroupLeft': - this.disableSyncplay(true); + this.disableSyncPlay(true); break; case 'GroupWait': toast({ - text: globalize.translate('MessageSyncplayGroupWait', cmd.Data) + text: globalize.translate('MessageSyncPlayGroupWait', cmd.Data) }); break; case 'GroupDoesNotExist': toast({ - text: globalize.translate('MessageSyncplayGroupDoesNotExist') + text: globalize.translate('MessageSyncPlayGroupDoesNotExist') }); break; case 'CreateGroupDenied': toast({ - text: globalize.translate('MessageSyncplayCreateGroupDenied') + text: globalize.translate('MessageSyncPlayCreateGroupDenied') }); break; case 'JoinGroupDenied': toast({ - text: globalize.translate('MessageSyncplayJoinGroupDenied') + text: globalize.translate('MessageSyncPlayJoinGroupDenied') }); break; case 'LibraryAccessDenied': toast({ - text: globalize.translate('MessageSyncplayLibraryAccessDenied') + text: globalize.translate('MessageSyncPlayLibraryAccessDenied') }); break; default: - console.error('processSyncplayGroupUpdate: command is not recognised: ' + cmd.Type); + console.error('processSyncPlayGroupUpdate: command is not recognised: ' + cmd.Type); break; } } @@ -360,13 +360,13 @@ class SyncplayManager { processCommand (cmd, apiClient) { if (cmd === null) return; - if (!this.isSyncplayEnabled()) { - console.debug('Syncplay processCommand: SyncPlay not enabled, ignoring command', cmd); + if (!this.isSyncPlayEnabled()) { + console.debug('SyncPlay processCommand: SyncPlay not enabled, ignoring command', cmd); return; } - if (!this.syncplayReady) { - console.debug('Syncplay processCommand: SyncPlay not ready, queued command', cmd); + if (!this.syncPlayReady) { + console.debug('SyncPlay processCommand: SyncPlay not ready, queued command', cmd); this.queuedCommand = cmd; return; } @@ -374,8 +374,8 @@ class SyncplayManager { cmd.When = new Date(cmd.When); cmd.EmittedAt = new Date(cmd.EmitttedAt); - if (cmd.EmitttedAt < this.syncplayEnabledAt) { - console.debug('Syncplay processCommand: ignoring old command', cmd); + if (cmd.EmitttedAt < this.syncPlayEnabledAt) { + console.debug('SyncPlay processCommand: ignoring old command', cmd); return; } @@ -385,12 +385,12 @@ class SyncplayManager { this.lastCommand.PositionTicks === cmd.PositionTicks && this.Command === cmd.Command ) { - console.debug('Syncplay processCommand: ignoring duplicate command', cmd); + console.debug('SyncPlay processCommand: ignoring duplicate command', cmd); return; } this.lastCommand = cmd; - console.log('Syncplay will', cmd.Command, 'at', cmd.When, 'PositionTicks', cmd.PositionTicks); + console.log('SyncPlay will', cmd.Command, 'at', cmd.When, 'PositionTicks', cmd.PositionTicks); switch (cmd.Command) { case 'Play': @@ -430,7 +430,7 @@ class SyncplayManager { if (!sessionId) { console.error('Missing sessionId!'); toast({ - text: globalize.translate('MessageSyncplayErrorMissingSession') + text: globalize.translate('MessageSyncPlayErrorMissingSession') }); return; } @@ -448,22 +448,22 @@ class SyncplayManager { if (!success) { console.warning('Error reporting playback state to server. Joining group will fail.'); } - apiClient.sendSyncplayCommand(sessionId, 'JoinGroup', { + apiClient.sendSyncPlayCommand(sessionId, 'JoinGroup', { GroupId: groupId, PlayingItemId: playingItemId }); }).catch(() => { console.error('Timed out while waiting for `reportplayback` event!'); toast({ - text: globalize.translate('MessageSyncplayErrorMedia') + text: globalize.translate('MessageSyncPlayErrorMedia') }); return; }); }).catch(() => { console.error('Timed out while waiting for `playbackstart` event!'); - if (!this.isSyncplayEnabled()) { + if (!this.isSyncPlayEnabled()) { toast({ - text: globalize.translate('MessageSyncplayErrorMedia') + text: globalize.translate('MessageSyncPlayErrorMedia') }); } return; @@ -471,19 +471,19 @@ class SyncplayManager { }).catch((error) => { console.error(error); toast({ - text: globalize.translate('MessageSyncplayErrorMedia') + text: globalize.translate('MessageSyncPlayErrorMedia') }); }); } /** - * Enables Syncplay. + * Enables SyncPlay. * @param {Object} apiClient The ApiClient. - * @param {Date} enabledAt When Syncplay has been enabled. Server side date. + * @param {Date} enabledAt When SyncPlay has been enabled. Server side date. * @param {boolean} showMessage Display message. */ - enableSyncplay (apiClient, enabledAt, showMessage = false) { - this.syncplayEnabledAt = enabledAt; + enableSyncPlay (apiClient, enabledAt, showMessage = false) { + this.syncPlayEnabledAt = enabledAt; this.injectPlaybackManager(); events.trigger(this, 'enabled', [true]); @@ -492,25 +492,25 @@ class SyncplayManager { this.queuedCommand = null; }); - this.syncplayReady = false; - this.notifySyncplayReady = true; + this.syncPlayReady = false; + this.notifySyncPlayReady = true; timeSyncManager.forceUpdate(); if (showMessage) { toast({ - text: globalize.translate('MessageSyncplayEnabled') + text: globalize.translate('MessageSyncPlayEnabled') }); } } /** - * Disables Syncplay. + * Disables SyncPlay. * @param {boolean} showMessage Display message. */ - disableSyncplay (showMessage = false) { - this.syncplayEnabledAt = null; - this.syncplayReady = false; + disableSyncPlay (showMessage = false) { + this.syncPlayEnabledAt = null; + this.syncPlayReady = false; this.lastCommand = null; this.queuedCommand = null; this.syncEnabled = false; @@ -519,17 +519,17 @@ class SyncplayManager { if (showMessage) { toast({ - text: globalize.translate('MessageSyncplayDisabled') + text: globalize.translate('MessageSyncPlayDisabled') }); } } /** - * Gets Syncplay status. + * Gets SyncPlay status. * @returns {boolean} _true_ if user joined a group, _false_ otherwise. */ - isSyncplayEnabled () { - return this.syncplayEnabledAt !== null; + isSyncPlayEnabled () { + return this.syncPlayEnabledAt !== null; } /** @@ -627,8 +627,8 @@ class SyncplayManager { * Overrides some PlaybackManager's methods to intercept playback commands. */ injectPlaybackManager () { - if (!this.isSyncplayEnabled()) return; - if (playbackManager.syncplayEnabled) return; + if (!this.isSyncPlayEnabled()) return; + if (playbackManager.syncPlayEnabled) return; // TODO: make this less hacky playbackManager._localUnpause = playbackManager.unpause; @@ -638,20 +638,20 @@ class SyncplayManager { playbackManager.unpause = this.playRequest; playbackManager.pause = this.pauseRequest; playbackManager.seek = this.seekRequest; - playbackManager.syncplayEnabled = true; + playbackManager.syncPlayEnabled = true; } /** * Restores original PlaybackManager's methods. */ restorePlaybackManager () { - if (this.isSyncplayEnabled()) return; - if (!playbackManager.syncplayEnabled) return; + if (this.isSyncPlayEnabled()) return; + if (!playbackManager.syncPlayEnabled) return; playbackManager.unpause = playbackManager._localUnpause; playbackManager.pause = playbackManager._localPause; playbackManager.seek = playbackManager._localSeek; - playbackManager.syncplayEnabled = false; + playbackManager.syncPlayEnabled = false; } /** @@ -660,7 +660,7 @@ class SyncplayManager { playRequest (player) { var apiClient = connectionManager.currentApiClient(); var sessionId = getActivePlayerId(); - apiClient.sendSyncplayCommand(sessionId, 'PlayRequest'); + apiClient.sendSyncPlayCommand(sessionId, 'PlayRequest'); } /** @@ -669,7 +669,7 @@ class SyncplayManager { pauseRequest (player) { var apiClient = connectionManager.currentApiClient(); var sessionId = getActivePlayerId(); - apiClient.sendSyncplayCommand(sessionId, 'PauseRequest'); + apiClient.sendSyncPlayCommand(sessionId, 'PauseRequest'); // Pause locally as well, to give the user some little control playbackManager._localUnpause(player); } @@ -680,7 +680,7 @@ class SyncplayManager { seekRequest (PositionTicks, player) { var apiClient = connectionManager.currentApiClient(); var sessionId = getActivePlayerId(); - apiClient.sendSyncplayCommand(sessionId, 'SeekRequest', { + apiClient.sendSyncPlayCommand(sessionId, 'SeekRequest', { PositionTicks: PositionTicks }); } @@ -689,7 +689,7 @@ class SyncplayManager { * Calls original PlaybackManager's unpause method. */ localUnpause(player) { - if (playbackManager.syncplayEnabled) { + if (playbackManager.syncPlayEnabled) { playbackManager._localUnpause(player); } else { playbackManager.unpause(player); @@ -700,7 +700,7 @@ class SyncplayManager { * Calls original PlaybackManager's pause method. */ localPause(player) { - if (playbackManager.syncplayEnabled) { + if (playbackManager.syncPlayEnabled) { playbackManager._localPause(player); } else { playbackManager.pause(player); @@ -711,7 +711,7 @@ class SyncplayManager { * Calls original PlaybackManager's seek method. */ localSeek(PositionTicks, player) { - if (playbackManager.syncplayEnabled) { + if (playbackManager.syncPlayEnabled) { playbackManager._localSeek(PositionTicks, player); } else { playbackManager.seek(PositionTicks, player); @@ -800,8 +800,8 @@ class SyncplayManager { } /** - * Gets Syncplay stats. - * @returns {Object} The Syncplay stats. + * Gets SyncPlay stats. + * @returns {Object} The SyncPlay stats. */ getStats () { return { @@ -812,7 +812,7 @@ class SyncplayManager { } /** - * Emits an event to update the Syncplay status icon. + * Emits an event to update the SyncPlay status icon. */ showSyncIcon (syncMethod) { this.syncMethod = syncMethod; @@ -820,7 +820,7 @@ class SyncplayManager { } /** - * Emits an event to clear the Syncplay status icon. + * Emits an event to clear the SyncPlay status icon. */ clearSyncIcon () { this.syncMethod = 'None'; @@ -828,12 +828,12 @@ class SyncplayManager { } /** - * Signals an error state, which disables and resets Syncplay for a new session. + * Signals an error state, which disables and resets SyncPlay for a new session. */ signalError () { - this.disableSyncplay(); + this.disableSyncPlay(); } } -/** SyncplayManager singleton. */ -export default new SyncplayManager(); +/** SyncPlayManager singleton. */ +export default new SyncPlayManager(); diff --git a/src/controllers/useredit.js b/src/controllers/useredit.js index cbe5acd36a..125c571362 100644 --- a/src/controllers/useredit.js +++ b/src/controllers/useredit.js @@ -104,7 +104,7 @@ define(['jQuery', 'loading', 'libraryMenu', 'globalize', 'fnchecked'], function $('#chkEnableSharing', page).checked(user.Policy.EnablePublicSharing); $('#txtRemoteClientBitrateLimit', page).val(user.Policy.RemoteClientBitrateLimit / 1e6 || ''); $('#txtLoginAttemptsBeforeLockout', page).val(user.Policy.LoginAttemptsBeforeLockout || '0'); - $('#selectSyncplayAccess').val(user.Policy.SyncplayAccess); + $('#selectSyncPlayAccess').val(user.Policy.SyncPlayAccess); loading.hide(); } @@ -146,7 +146,7 @@ define(['jQuery', 'loading', 'libraryMenu', 'globalize', 'fnchecked'], function }).map(function (c) { return c.getAttribute('data-id'); }); - user.Policy.SyncplayAccess = page.querySelector('#selectSyncplayAccess').value; + user.Policy.SyncPlayAccess = page.querySelector('#selectSyncPlayAccess').value; ApiClient.updateUser(user).then(function () { ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () { onSaveComplete(page, user); diff --git a/src/scripts/librarymenu.js b/src/scripts/librarymenu.js index 3113031ea8..a7aaeaa790 100644 --- a/src/scripts/librarymenu.js +++ b/src/scripts/librarymenu.js @@ -1,4 +1,4 @@ -define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'syncplayManager', 'groupSelectionMenu', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, syncplayManager, groupSelectionMenu, browser, globalize, imageHelper) { +define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'syncPlayManager', 'groupSelectionMenu', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, syncPlayManager, groupSelectionMenu, browser, globalize, imageHelper) { 'use strict'; function renderHeader() { @@ -89,7 +89,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' var policy = user.Policy ? user.Policy : user.localUser.Policy; - if (headerSyncButton && policy && policy.SyncplayAccess !== 'None') { + if (headerSyncButton && policy && policy.SyncPlayAccess !== 'None') { headerSyncButton.classList.remove('hide'); } } else { @@ -192,7 +192,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' groupSelectionMenu.show(btn); } - function onSyncplayEnabled(event, enabled) { + function onSyncPlayEnabled(event, enabled) { var icon = headerSyncButton.querySelector('span'); icon.classList.remove('sync', 'sync_disabled', 'sync_problem'); if (enabled) { @@ -202,7 +202,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' } } - function onSyncplaySyncing(event, is_syncing, syncMethod) { + function onSyncPlaySyncing(event, is_syncing, syncMethod) { var icon = headerSyncButton.querySelector('span'); icon.classList.remove('sync', 'sync_disabled', 'sync_problem'); if (is_syncing) { @@ -967,8 +967,8 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' updateUserInHeader(); }); events.on(playbackManager, 'playerchange', updateCastIcon); - events.on(syncplayManager, 'enabled', onSyncplayEnabled); - events.on(syncplayManager, 'syncing', onSyncplaySyncing); + events.on(syncPlayManager, 'enabled', onSyncPlayEnabled); + events.on(syncPlayManager, 'syncing', onSyncPlaySyncing); loadNavDrawer(); return LibraryMenu; }); diff --git a/src/scripts/site.js b/src/scripts/site.js index a07062ab3c..3cf6054603 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -826,7 +826,7 @@ var AppInfo = {}; define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager); define('timeSyncManager', [componentsPath + '/syncplay/timeSyncManager'], returnDefault); define('groupSelectionMenu', [componentsPath + '/syncplay/groupSelectionMenu'], returnFirstDependency); - define('syncplayManager', [componentsPath + '/syncplay/syncplayManager'], returnDefault); + define('syncPlayManager', [componentsPath + '/syncplay/syncPlayManager'], returnDefault); define('playbackPermissionManager', [componentsPath + '/syncplay/playbackPermissionManager'], returnDefault); define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager); define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 4759b670d8..495f90edb5 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -491,8 +491,8 @@ "HeaderSubtitleProfile": "Subtitle Profile", "HeaderSubtitleProfiles": "Subtitle Profiles", "HeaderSubtitleProfilesHelp": "Subtitle profiles describe the subtitle formats supported by the device.", - "HeaderSyncplaySelectGroup": "Join a group", - "HeaderSyncplayEnabled": "Syncplay enabled", + "HeaderSyncPlaySelectGroup": "Join a group", + "HeaderSyncPlayEnabled": "SyncPlay enabled", "HeaderSystemDlnaProfiles": "System Profiles", "HeaderTags": "Tags", "HeaderTaskTriggers": "Task Triggers", @@ -855,18 +855,18 @@ "LabelSubtitlePlaybackMode": "Subtitle mode:", "LabelSubtitles": "Subtitles", "LabelSupportedMediaTypes": "Supported Media Types:", - "LabelSyncplayTimeOffset": "Time offset with the server:", + "LabelSyncPlayTimeOffset": "Time offset with the server:", "MillisecondsUnit": "ms", - "LabelSyncplayPlaybackDiff": "Playback time difference:", - "LabelSyncplaySyncMethod": "Sync method:", - "LabelSyncplayNewGroup": "New group", - "LabelSyncplayNewGroupDescription": "Create a new group", - "LabelSyncplayLeaveGroup": "Leave group", - "LabelSyncplayLeaveGroupDescription": "Disable Syncplay", - "LabelSyncplayAccessCreateAndJoinGroups": "Allow user to create and join groups", - "LabelSyncplayAccessJoinGroups": "Allow user to join groups", - "LabelSyncplayAccessNone": "Disabled for this user", - "LabelSyncplayAccess": "Syncplay access", + "LabelSyncPlayPlaybackDiff": "Playback time difference:", + "LabelSyncPlaySyncMethod": "Sync method:", + "LabelSyncPlayNewGroup": "New group", + "LabelSyncPlayNewGroupDescription": "Create a new group", + "LabelSyncPlayLeaveGroup": "Leave group", + "LabelSyncPlayLeaveGroupDescription": "Disable SyncPlay", + "LabelSyncPlayAccessCreateAndJoinGroups": "Allow user to create and join groups", + "LabelSyncPlayAccessJoinGroups": "Allow user to join groups", + "LabelSyncPlayAccessNone": "Disabled for this user", + "LabelSyncPlayAccess": "SyncPlay access", "LabelTVHomeScreen": "TV mode home screen:", "LabelTag": "Tag:", "LabelTagline": "Tagline:", @@ -1030,21 +1030,21 @@ "MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.", "MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.", "MessageYouHaveVersionInstalled": "You currently have version {0} installed.", - "MessageSyncplayEnabled": "Syncplay enabled.", - "MessageSyncplayDisabled": "Syncplay disabled.", - "MessageSyncplayUserJoined": "{0} has joined the group.", - "MessageSyncplayUserLeft": "{0} has left the group.", - "MessageSyncplayGroupWait": "{0} is buffering...", - "MessageSyncplayNoGroupsAvailable": "No groups available. Start playing something first.", - "MessageSyncplayPlaybackPermissionRequired": "Playback permission required.", - "MessageSyncplayGroupDoesNotExist": "Failed to join group because it does not exist.", - "MessageSyncplayCreateGroupDenied": "Permission required to create a group.", - "MessageSyncplayJoinGroupDenied": "Permission required to use Syncplay.", - "MessageSyncplayLibraryAccessDenied": "Access to this content is restricted.", - "MessageSyncplayErrorAccessingGroups": "An error occurred while accessing groups list.", - "MessageSyncplayErrorNoActivePlayer": "No active player found. Syncplay has been disabled.", - "MessageSyncplayErrorMissingSession": "Failed to enable Syncplay! Missing session.", - "MessageSyncplayErrorMedia": "Failed to enable Syncplay! Media error.", + "MessageSyncPlayEnabled": "SyncPlay enabled.", + "MessageSyncPlayDisabled": "SyncPlay disabled.", + "MessageSyncPlayUserJoined": "{0} has joined the group.", + "MessageSyncPlayUserLeft": "{0} has left the group.", + "MessageSyncPlayGroupWait": "{0} is buffering...", + "MessageSyncPlayNoGroupsAvailable": "No groups available. Start playing something first.", + "MessageSyncPlayPlaybackPermissionRequired": "Playback permission required.", + "MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist.", + "MessageSyncPlayCreateGroupDenied": "Permission required to create a group.", + "MessageSyncPlayJoinGroupDenied": "Permission required to use SyncPlay.", + "MessageSyncPlayLibraryAccessDenied": "Access to this content is restricted.", + "MessageSyncPlayErrorAccessingGroups": "An error occurred while accessing groups list.", + "MessageSyncPlayErrorNoActivePlayer": "No active player found. SyncPlay has been disabled.", + "MessageSyncPlayErrorMissingSession": "Failed to enable SyncPlay! Missing session.", + "MessageSyncPlayErrorMedia": "Failed to enable SyncPlay! Media error.", "Metadata": "Metadata", "MetadataManager": "Metadata Manager", "MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.", @@ -1393,7 +1393,7 @@ "Suggestions": "Suggestions", "Sunday": "Sunday", "Sync": "Sync", - "SyncplayAccessHelp": "Select the level of access this user has to the Syncplay feature. Syncplay enables to sync playback with other users.", + "SyncPlayAccessHelp": "Select the level of access this user has to the SyncPlay feature. SyncPlay enables to sync playback with other users.", "SystemDlnaProfilesHelp": "System profiles are read-only. Changes to a system profile will be saved to a new custom profile.", "TV": "TV", "TabAccess": "Access", diff --git a/src/useredit.html b/src/useredit.html index 5f759c973c..2a8f17af47 100644 --- a/src/useredit.html +++ b/src/useredit.html @@ -105,13 +105,13 @@
-
- + + + -
${SyncplayAccessHelp}
+
${SyncPlayAccessHelp}
From f9a2e9ac38835ef32b3d0daada503092eb3f2212 Mon Sep 17 00:00:00 2001 From: gion Date: Wed, 27 May 2020 11:12:15 +0200 Subject: [PATCH 14/14] Update CONTRIBUTORS.md --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2eae7e6933..73f40aaca1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -35,6 +35,7 @@ - [Thibault Nocchi](https://github.com/ThibaultNocchi) - [MrTimscampi](https://github.com/MrTimscampi) - [Sarab Singh](https://github.com/sarab97) + - [Andrei Oanca](https://github.com/OancaAndrei) # Emby Contributors