diff --git a/syncplay/client.py b/syncplay/client.py index a42a045..54faabc 100644 --- a/syncplay/client.py +++ b/syncplay/client.py @@ -111,6 +111,8 @@ class SyncplayClient(object): self.autoPlay = False self.autoPlayThreshold = None + self._lastPlayerCommand = time.time() + self.autoplayTimer = task.LoopingCall(self.autoplayCountdown) self.autoplayTimeLeft = constants.AUTOPLAY_DELAY @@ -148,6 +150,16 @@ class SyncplayClient(object): def playerIsNotReady(self): return self._player is None + def _playerRequest(self, f, *args, **kwargs): + """Send a request with cookie to the player.""" + kwargs['cookie'] = time.time() + f(*args, **kwargs) + + def _playerCommand(self, f, *args, **kwargs): + """Send a command to the player, affecting cookie freshness.""" + self._lastPlayerCommand = time.time() + f(*args, **kwargs) + def scheduleAskPlayer(self, when=constants.PLAYER_ASK_DELAY): self._askPlayerTimer = task.LoopingCall(self.askPlayer) self._askPlayerTimer.start(when) @@ -156,7 +168,7 @@ class SyncplayClient(object): if not self._running: return if self._player: - self._player.askForStatus() + self._playerRequest(self._player.askForStatus) self.checkIfConnected() def checkIfConnected(self): @@ -177,11 +189,22 @@ class SyncplayClient(object): def rewindFile(self): self.setPosition(0) - def updatePlayerStatus(self, paused, position): + def _ignoringPlayerStatus(self, cookie=None): + if cookie is None: + cookie = time.time() + return cookie < self._lastPlayerCommand + self._config['playerCommandDelay'] + + def updatePlayerStatus(self, paused, position, cookie=None): + # Ignore status report if the cookie is stale + if self._ignoringPlayerStatus(cookie): + self.ui.showDebugMessage('Ignoring stale player status with cookie {}'.format(cookie)) + return + position -= self.getUserOffset() pauseChange, seeked = self._determinePlayerStateChange(paused, position) self._playerPosition = position self._playerPaused = paused + currentLength = self.userlist.currentUser.file["duration"] if self.userlist.currentUser.file else 0 if pauseChange and paused and currentLength > constants.PLAYLIST_LOAD_NEXT_FILE_MINIMUM_LENGTH\ and abs(position - currentLength ) < constants.PLAYLIST_LOAD_NEXT_FILE_TIME_FROM_END_THRESHOLD: @@ -198,7 +221,7 @@ class SyncplayClient(object): def _toggleReady(self, pauseChange, paused): if not self.userlist.currentUser.canControl(): - self._player.setPaused(self._globalPaused) + self._playerCommand(self._player.setPaused, self._globalPaused) self.toggleReady(manuallyInitiated=True) self._playerPaused = self._globalPaused pauseChange = False @@ -208,7 +231,7 @@ class SyncplayClient(object): self.ui.showMessage(getMessage("set-as-not-ready-notification")) elif not paused and not self.instaplayConditionsMet(): paused = True - self._player.setPaused(paused) + self._playerCommand(self._player.setPaused, paused) self._playerPaused = paused self.changeReadyState(True, manuallyInitiated=True) pauseChange = False @@ -236,7 +259,7 @@ class SyncplayClient(object): def _initPlayerState(self, position, paused): if self.userlist.currentUser.file: self.setPosition(position) - self._player.setPaused(paused) + self._playerCommand(self._player.setPaused, paused) madeChangeOnPlayer = True return madeChangeOnPlayer @@ -264,16 +287,21 @@ class SyncplayClient(object): def _serverUnpaused(self, setBy): hideFromOSD = not constants.SHOW_SAME_ROOM_OSD - self._player.setPaused(False) + # In high-player-latency situations we might report our state back to + # the server before any player status is accepted as fresh. Override + # the locally-stored playback state. + self._playerPaused = False + self._playerCommand(self._player.setPaused, False) madeChangeOnPlayer = True self.ui.showMessage(getMessage("unpause-notification").format(setBy), hideFromOSD) return madeChangeOnPlayer def _serverPaused(self, setBy): hideFromOSD = not constants.SHOW_SAME_ROOM_OSD + self._playerPaused = True if constants.SYNC_ON_PAUSE and self.getUsername() <> setBy: self.setPosition(self.getGlobalPosition()) - self._player.setPaused(True) + self._playerCommand(self._player.setPaused, True) madeChangeOnPlayer = True if (self.lastLeftTime < time.time() - constants.OSD_DURATION) or (hideFromOSD == True): self.ui.showMessage(getMessage("pause-notification").format(setBy), hideFromOSD) @@ -541,18 +569,19 @@ class SyncplayClient(object): def setPosition(self, position): if self._lastPlayerUpdate: self._lastPlayerUpdate = time.time() + self._playerPosition = position position += self.getUserOffset() if self._player and self.userlist.currentUser.file: if position < 0: position = 0 self._protocol.sendState(self.getPlayerPosition(), self.getPlayerPaused(), True, None, True) - self._player.setPosition(position) + self._playerCommand(self._player.setPosition, position) def setPaused(self, paused): if self._player and self.userlist.currentUser.file: if self._lastPlayerUpdate and not paused: self._lastPlayerUpdate = time.time() - self._player.setPaused(paused) + self._playerCommand(self._player.setPaused, paused) def start(self, host, port): if self._running: diff --git a/syncplay/constants.py b/syncplay/constants.py index 790ab6c..488093f 100644 --- a/syncplay/constants.py +++ b/syncplay/constants.py @@ -52,6 +52,7 @@ SERVER_STATE_INTERVAL = 1 WARNING_OSD_MESSAGES_LOOP_INTERVAL = 1 AUTOPLAY_DELAY = 3.0 SYNC_ON_PAUSE = True # Client seek to global position - subtitles may disappear on some media players +DEFAULT_PLAYER_COMMAND_DELAY = 0.05 # Options for the File Switch feature: FOLDER_SEARCH_TIMEOUT = 60.0 # Secs - How long to wait until searches in folder to update cache are aborted (may be longer than this if hard drive needs to spin up) diff --git a/syncplay/messages.py b/syncplay/messages.py index 51211cf..3624500 100755 --- a/syncplay/messages.py +++ b/syncplay/messages.py @@ -200,6 +200,9 @@ en = { "readyatstart-label" : "Set me as 'ready to watch' by default", "forceguiprompt-label" : "Don't always show the Syncplay configuration window", # (Inverted) "showosd-label" : "Enable OSD Messages", + "playercommanddelay-title" : "Player latency compensation", + "playercommanddelay-label" : "Seconds to ignore player status after commands", + "playercommanddelay-tooltip" : "Larger values are less likely to spuriously (un)pause but tend to sync less accurately.", "showosdwarnings-label" : "Include warnings (e.g. when files are different, users not ready)", "showsameroomosd-label" : "Include events in your room", diff --git a/syncplay/players/basePlayer.py b/syncplay/players/basePlayer.py index c8fdcc2..37b81d7 100644 --- a/syncplay/players/basePlayer.py +++ b/syncplay/players/basePlayer.py @@ -6,7 +6,7 @@ class BasePlayer(object): execute updatePlayerStatus(paused, position) on client Given the arguments: boolean paused and float position in seconds ''' - def askForStatus(self): + def askForStatus(self, cookie=None): raise NotImplementedError() ''' @@ -121,4 +121,4 @@ class DummyPlayer(BasePlayer): @staticmethod def getPlayerPathErrors(playerPath, filePath): - return None \ No newline at end of file + return None diff --git a/syncplay/players/mpc.py b/syncplay/players/mpc.py index 6ab1452..75e0ac7 100644 --- a/syncplay/players/mpc.py +++ b/syncplay/players/mpc.py @@ -2,7 +2,8 @@ import time import threading import thread -import win32con, win32api, win32gui, ctypes, ctypes.wintypes #@UnresolvedImport @UnusedImport +# noinspection PyUnresolvedReferences +import win32con, win32api, win32gui, ctypes, ctypes.wintypes from functools import wraps from syncplay.players.basePlayer import BasePlayer import re @@ -419,7 +420,7 @@ class MPCHCAPIPlayer(BasePlayer): self.__positionUpdate.wait(constants.MPC_LOCK_WAIT_TIME) return self._mpcApi.lastFilePosition - def askForStatus(self): + def askForStatus(self, cookie=None): try: if self._mpcApi.filePlaying and self.__preventAsking.wait(0) and self.__fileUpdate.acquire(0): self.__fileUpdate.release() @@ -427,15 +428,17 @@ class MPCHCAPIPlayer(BasePlayer): paused = self._mpcApi.isPaused() position = float(position) if self.__preventAsking.wait(0) and self.__fileUpdate.acquire(0): - self.__client.updatePlayerStatus(paused, position) + self.__client.updatePlayerStatus(paused, position, cookie=cookie) self.__fileUpdate.release() else: - self.__echoGlobalStatus() + self.__echoGlobalStatus(cookie) except MpcHcApi.PlayerNotReadyException: - self.__echoGlobalStatus() + self.__echoGlobalStatus(cookie) - def __echoGlobalStatus(self): - self.__client.updatePlayerStatus(self.__client.getGlobalPaused(), self.__client.getGlobalPosition()) + def __echoGlobalStatus(self, cookie): + self.__client.updatePlayerStatus(self.__client.getGlobalPaused(), + self.__client.getGlobalPosition(), + cookie=cookie) def __forcePause(self): for _ in xrange(constants.MPC_MAX_RETRIES): diff --git a/syncplay/players/mplayer.py b/syncplay/players/mplayer.py index 4c505a3..dc567b9 100644 --- a/syncplay/players/mplayer.py +++ b/syncplay/players/mplayer.py @@ -72,14 +72,14 @@ class MplayerPlayer(BasePlayer): self.reactor.callLater(0, self._client.initPlayer, self) self._onFileUpdate() - def askForStatus(self): + def askForStatus(self, cookie=None): self._positionAsk.clear() self._pausedAsk.clear() self._getPaused() self._getPosition() self._positionAsk.wait() self._pausedAsk.wait() - self._client.updatePlayerStatus(self._paused, self._position) + self._client.updatePlayerStatus(self._paused, self._position, cookie=cookie) def _setProperty(self, property_, value): self._listener.sendLine("set_property {} {}".format(property_, value)) diff --git a/syncplay/players/mpv.py b/syncplay/players/mpv.py index cc9a66d..c3cf44b 100644 --- a/syncplay/players/mpv.py +++ b/syncplay/players/mpv.py @@ -160,13 +160,15 @@ class NewMpvPlayer(OldMpvPlayer): else: self._paused = self._client.getGlobalPaused() - def askForStatus(self): + def askForStatus(self, cookie=None): self._positionAsk.clear() self._pausedAsk.clear() self._getPausedAndPosition() self._positionAsk.wait(constants.MPV_LOCK_WAIT_TIME) self._pausedAsk.wait(constants.MPV_LOCK_WAIT_TIME) - self._client.updatePlayerStatus(self._paused if self.fileLoaded else self._client.getGlobalPaused(), self.getCalculatedPosition()) + self._client.updatePlayerStatus(self._paused if self.fileLoaded else self._client.getGlobalPaused(), + self.getCalculatedPosition(), + cookie=cookie) def _getPausedAndPosition(self): self._listener.sendLine(u"print_text ANS_pause=${pause}\r\nprint_text ANS_time-pos=${=time-pos}") @@ -232,4 +234,4 @@ class NewMpvPlayer(OldMpvPlayer): if self.fileLoaded == True and self.lastLoadedTime != None and time.time() > (self.lastLoadedTime + constants.MPV_NEWFILE_IGNORE_TIME): return True else: - return False \ No newline at end of file + return False diff --git a/syncplay/players/vlc.py b/syncplay/players/vlc.py index bdeba7d..a6cdcff 100644 --- a/syncplay/players/vlc.py +++ b/syncplay/players/vlc.py @@ -83,16 +83,20 @@ class VlcPlayer(BasePlayer): self.setPaused(self._client.getGlobalPaused()) self.setPosition(self._client.getGlobalPosition()) - def askForStatus(self): + def askForStatus(self, cookie=None): self._filechanged = False self._positionAsk.clear() self._pausedAsk.clear() self._listener.sendLine(".") if self._filename and not self._filechanged: self._positionAsk.wait(constants.PLAYER_ASK_DELAY) - self._client.updatePlayerStatus(self._paused, self.getCalculatedPosition()) + self._client.updatePlayerStatus(self._paused, + self.getCalculatedPosition(), + cookie=cookie) else: - self._client.updatePlayerStatus(self._client.getGlobalPaused(), self._client.getGlobalPosition()) + self._client.updatePlayerStatus(self._client.getGlobalPaused(), + self._client.getGlobalPosition(), + cookie=cookie) def getCalculatedPosition(self): if self._lastVLCPositionUpdate is None: diff --git a/syncplay/ui/ConfigurationGetter.py b/syncplay/ui/ConfigurationGetter.py index dd42523..7f22674 100755 --- a/syncplay/ui/ConfigurationGetter.py +++ b/syncplay/ui/ConfigurationGetter.py @@ -65,7 +65,8 @@ class ConfigurationGetter(object): "showSameRoomOSD" : True, "showNonControllerOSD" : False, "showContactInfo" : True, - "showDurationNotification" : True + "showDurationNotification" : True, + "playerCommandDelay": constants.DEFAULT_PLAYER_COMMAND_DELAY } self._defaultConfig = self._config.copy() @@ -121,13 +122,26 @@ class ConfigurationGetter(object): "rewindThreshold", "fastforwardThreshold", "autoplayMinUsers", + "playerCommandDelay", ] self._iniStructure = { "server_data": ["host", "port", "password"], - "client_settings": ["name", "room", "playerPath", "perPlayerArguments", "slowdownThreshold", "rewindThreshold", "fastforwardThreshold", "slowOnDesync", "rewindOnDesync", "fastforwardOnDesync", "dontSlowDownWithMe", "forceGuiPrompt", "filenamePrivacyMode", "filesizePrivacyMode", "unpauseAction", "pauseOnLeave", "readyAtStart", "autoplayMinUsers", "autoplayInitialState", "autoplayRequireSameFilenames", "mediaSearchDirectories", "sharedPlaylistEnabled"], - "gui": ["showOSD", "showOSDWarnings", "showSlowdownOSD", "showDifferentRoomOSD", "showSameRoomOSD", "showNonControllerOSD", "showDurationNotification"], - "general": ["language", "checkForUpdatesAutomatically", "lastCheckedForUpdates"] + "client_settings": ["name", "room", "playerPath", + "perPlayerArguments", "slowdownThreshold", + "rewindThreshold", "fastforwardThreshold", + "slowOnDesync", "rewindOnDesync", + "fastforwardOnDesync", "dontSlowDownWithMe", + "forceGuiPrompt", "filenamePrivacyMode", + "filesizePrivacyMode", "unpauseAction", + "pauseOnLeave", "readyAtStart", "autoplayMinUsers", + "autoplayInitialState", "mediaSearchDirectories", + "sharedPlaylistEnabled", "playerCommandDelay"], + "gui": ["showOSD", "showOSDWarnings", "showSlowdownOSD", + "showDifferentRoomOSD", "showSameRoomOSD", + "showNonControllerOSD", "showDurationNotification"], + "general": ["language", "checkForUpdatesAutomatically", + "lastCheckedForUpdates"] } self._playerFactory = PlayerFactory() diff --git a/syncplay/ui/GuiConfiguration.py b/syncplay/ui/GuiConfiguration.py index b0ac598..82ef13a 100644 --- a/syncplay/ui/GuiConfiguration.py +++ b/syncplay/ui/GuiConfiguration.py @@ -416,6 +416,8 @@ class ConfigDialog(QtGui.QDialog): widget.setChecked(True) elif isinstance(widget, QLineEdit): widget.setText(self.config[valueName]) + elif isinstance(widget, QDoubleSpinBox): + widget.setValue(self.config[valueName]) def saveValues(self, widget): valueName = str(widget.objectName()) @@ -438,6 +440,8 @@ class ConfigDialog(QtGui.QDialog): self.config[radioName] = radioValue elif isinstance(widget, QLineEdit): self.config[valueName] = widget.text() + elif isinstance(widget, QDoubleSpinBox): + self.config[valueName] = widget.value() def connectChildren(self, widget): widgetName = str(widget.objectName()) @@ -733,6 +737,10 @@ class ConfigDialog(QtGui.QDialog): self.rewindCheckbox.setObjectName("rewindOnDesync") self.fastforwardCheckbox = QCheckBox(getMessage("fastforwardondesync-label")) self.fastforwardCheckbox.setObjectName("fastforwardOnDesync") + self.commandDelaySpinbox = QDoubleSpinBox() + self.commandDelaySpinbox.setObjectName("playerCommandDelay") + self.commandDelaySpinbox.setMaximum(10) + self.commandDelaySpinbox.setSingleStep(.1) self.desyncSettingsLayout = QtGui.QGridLayout() self.desyncSettingsLayout.setSpacing(2) @@ -761,10 +769,17 @@ class ConfigDialog(QtGui.QDialog): self.othersyncSettingsLayout.setAlignment(Qt.AlignLeft) self.othersyncSettingsLayout.addWidget(self.fastforwardCheckbox, 3, 0,1,2, Qt.AlignLeft) + self.playerLatencyGroup = QtGui.QGroupBox(getMessage("playercommanddelay-title")) + self.playerLatencyLayout = QtGui.QHBoxLayout() + self.playerLatencyGroup.setLayout(self.playerLatencyLayout) + self.playerLatencyLayout.addWidget(self.commandDelaySpinbox) + self.playerLatencyLayout.addWidget(QLabel(getMessage("playercommanddelay-label"))) + self.othersyncSettingsGroup.setLayout(self.othersyncSettingsLayout) self.othersyncSettingsGroup.setMaximumHeight(self.othersyncSettingsGroup.minimumSizeHint().height()) self.syncSettingsLayout.addWidget(self.othersyncSettingsGroup) self.syncSettingsLayout.addWidget(self.desyncSettingsGroup) + self.syncSettingsLayout.addWidget(self.playerLatencyGroup) self.syncSettingsFrame.setLayout(self.syncSettingsLayout) self.desyncSettingsGroup.setMaximumHeight(self.desyncSettingsGroup.minimumSizeHint().height()) self.syncSettingsLayout.setAlignment(Qt.AlignTop)