class MVCPlaybackControlGUI(PlaybackControlConsole): """ GUI implementation of MVCPlaybackControlBase """ nameFiltersChanged = Signal("QStringList") def __init__(self, config): assertMainThread() super().__init__(config) # state self.preventSeek = False self.beginTime = None self.timeRatio = 1.0 # gui srv = Services.getService("MainWindow") config.configLoaded.connect(self.restoreState) config.configAboutToSave.connect(self.saveState) self.config = config playbackMenu = srv.menuBar().addMenu("&Playback") style = QApplication.style() self.actStart = QAction(QIcon.fromTheme("media-playback-start", style.standardIcon(QStyle.SP_MediaPlay)), "Start Playback", self) self.actPause = QAction(QIcon.fromTheme("media-playback-pause", style.standardIcon(QStyle.SP_MediaPause)), "Pause Playback", self) self.actPause.setEnabled(False) self.actStepFwd = QAction(QIcon.fromTheme("media-seek-forward", style.standardIcon(QStyle.SP_MediaSeekForward)), "Step Forward", self) self.actStepBwd = QAction(QIcon.fromTheme("media-seek-backward", style.standardIcon(QStyle.SP_MediaSeekBackward)), "Step Backward", self) self.actSeekEnd = QAction(QIcon.fromTheme("media-skip-forward", style.standardIcon(QStyle.SP_MediaSkipForward)), "Seek End", self) self.actSeekBegin = QAction(QIcon.fromTheme("media-skip-backward", style.standardIcon(QStyle.SP_MediaSkipBackward)), "Seek Begin", self) self.actSetTimeFactor = {r : QAction("x 1/%d" % (1/r), self) if r < 1 else QAction("x %d" % r, self) for r in (1/8, 1/4, 1/2, 1, 2, 4, 8)} # pylint: disable=unnecessary-lambda # let's stay on the safe side and do not use emit as a slot... self.actStart.triggered.connect(lambda: self._startPlayback.emit()) self.actPause.triggered.connect(lambda: self._pausePlayback.emit()) self.actStepFwd.triggered.connect(lambda: self._stepForward.emit(self.selectedStream())) self.actStepBwd.triggered.connect(lambda: self._stepBackward.emit(self.selectedStream())) self.actSeekEnd.triggered.connect(lambda: self._seekEnd.emit()) self.actSeekBegin.triggered.connect(lambda: self._seekBeginning.emit()) # pylint: enable=unnecessary-lambda def setTimeFactor(newFactor): logger.debug("new time factor %f", newFactor) self._setTimeFactor.emit(newFactor) for r in self.actSetTimeFactor: logger.debug("adding action for time factor %f", r) self.actSetTimeFactor[r].triggered.connect(functools.partial(setTimeFactor, r)) self.dockWidget = srv.newDockWidget("PlaybackControl", None, Qt.LeftDockWidgetArea) self.dockWidgetContents = QWidget(self.dockWidget) self.dockWidget.setWidget(self.dockWidgetContents) toolLayout = QBoxLayout(QBoxLayout.TopToBottom, self.dockWidgetContents) toolLayout.setContentsMargins(0, 0, 0, 0) toolBar = QToolBar() toolLayout.addWidget(toolBar) toolBar.addAction(self.actSeekBegin) toolBar.addAction(self.actStepBwd) toolBar.addAction(self.actStart) toolBar.addAction(self.actPause) toolBar.addAction(self.actStepFwd) toolBar.addAction(self.actSeekEnd) playbackMenu.addAction(self.actSeekBegin) playbackMenu.addAction(self.actStepBwd) playbackMenu.addAction(self.actStart) playbackMenu.addAction(self.actPause) playbackMenu.addAction(self.actStepFwd) playbackMenu.addAction(self.actSeekEnd) playbackMenu.addSeparator() for r in self.actSetTimeFactor: playbackMenu.addAction(self.actSetTimeFactor[r]) self.timeRatioLabel = QLabel("x 1") self.timeRatioLabel.addActions(list(self.actSetTimeFactor.values())) self.timeRatioLabel.setContextMenuPolicy(Qt.ActionsContextMenu) toolBar.addSeparator() toolBar.addWidget(self.timeRatioLabel) contentsLayout = QGridLayout() toolLayout.addLayout(contentsLayout, 10) # now we add a position view self.positionSlider = QSlider(Qt.Horizontal, self.dockWidgetContents) self.beginLabel = QLabel(parent=self.dockWidgetContents) self.beginLabel.setAlignment(Qt.AlignLeft|Qt.AlignCenter) self.currentLabel = QLabel(parent=self.dockWidgetContents) self.currentLabel.setAlignment(Qt.AlignHCenter|Qt.AlignCenter) self.endLabel = QLabel(parent=self.dockWidgetContents) self.endLabel.setAlignment(Qt.AlignRight|Qt.AlignCenter) contentsLayout.addWidget(self.beginLabel, 0, 0, alignment=Qt.AlignLeft) contentsLayout.addWidget(self.currentLabel, 0, 1, alignment=Qt.AlignHCenter) contentsLayout.addWidget(self.endLabel, 0, 2, alignment=Qt.AlignRight) contentsLayout.addWidget(self.positionSlider, 1, 0, 1, 3) self.positionSlider.setTracking(False) self.positionSlider.valueChanged.connect(self.onSliderValueChanged, Qt.DirectConnection) self.positionSlider.sliderMoved.connect(self.displayPosition) # file browser self.browser = BrowserWidget(self.dockWidget) self.nameFiltersChanged.connect(self._onNameFiltersChanged, Qt.QueuedConnection) contentsLayout.addWidget(self.browser, 3, 0, 1, 3) contentsLayout.setRowStretch(3, 100) self.browser.activated.connect(self.browserActivated) self.actShowAllFiles = QAction("Show all files") self.actShowAllFiles.setCheckable(True) self.actShowAllFiles.setChecked(False) self.actShowAllFiles.toggled.connect(self._onShowAllFiles) playbackMenu.addSeparator() playbackMenu.addAction(self.actShowAllFiles) self.actGroupStream = QActionGroup(self) self.actGroupStream.setExclusionPolicy(QActionGroup.ExclusionPolicy.ExclusiveOptional) playbackMenu.addSeparator() self.actGroupStreamMenu = playbackMenu.addMenu("Step Stream") self._selectedStream = None self.recentSeqs = [QAction() for i in range(10)] playbackMenu.addSeparator() recentMenu = playbackMenu.addMenu("Recent") for a in self.recentSeqs: a.setVisible(False) a.triggered.connect(self.openRecent) recentMenu.addAction(a) self._supportedFeaturesChanged(set(), set()) def __del__(self): logger.internal("deleting playback control") def _onNameFiltersChanged(self, nameFilt): self.browser.setFilter(nameFilt) def _onShowAllFiles(self, enabled): self.fileSystemModel.setNameFilterDisables(enabled) def _supportedFeaturesChanged(self, featureset, nameFilters): """ overwritten from MVCPlaybackControlBase. This function is called from multiple threads, but not at the same time. :param featureset: the current featureset :return: """ assertMainThread() self.featureset = featureset self.actStepFwd.setEnabled("stepForward" in featureset) self.actStepBwd.setEnabled("stepBackward" in featureset) self.actSeekBegin.setEnabled("seekBeginning" in featureset) self.actSeekEnd.setEnabled("seekEnd" in featureset) self.positionSlider.setEnabled("seekTime" in featureset) self.browser.setEnabled("setSequence" in featureset) self.timeRatioLabel.setEnabled("setTimeFactor" in featureset) for f in self.actSetTimeFactor: self.actSetTimeFactor[f].setEnabled("setTimeFactor" in featureset) self.timeRatioLabel.setEnabled("setTimeFactor" in featureset) self.timeRatioLabel.setEnabled("setTimeFactor" in featureset) self.timeRatioLabel.setEnabled("setTimeFactor" in featureset) self.timeRatioLabel.setEnabled("setTimeFactor" in featureset) if "startPlayback" not in featureset: self.actStart.setEnabled(False) if "pausePlayback" not in featureset: self.actPause.setEnabled(False) logger.debug("current feature set: %s", featureset) logger.debug("Setting name filters of browser: %s", list(nameFilters)) self.nameFiltersChanged.emit(list(nameFilters)) super()._supportedFeaturesChanged(featureset, nameFilters) def scrollToCurrent(self): """ Scrolls to the current item in the browser :return: """ assertMainThread() c = self.browser.current() if c is not None: self.browser.scrollTo(c) def _sequenceOpened(self, filename, begin, end, streams): """ Notifies about an opened sequence. :param filename: the filename which has been opened :param begin: timestamp of sequence's first sample :param end: timestamp of sequence's last sample :param streams: list of streams in the sequence :return: None """ assertMainThread() self.beginTime = begin self.preventSeek = True self.positionSlider.setRange(0, end.toMSecsSinceEpoch() - begin.toMSecsSinceEpoch()) self.preventSeek = False self.beginLabel.setText(begin.toString("hh:mm:ss.zzz")) self.endLabel.setText(end.toString("hh:mm:ss.zzz")) self._currentTimestampChanged(begin) try: self.browser.blockSignals(True) self.browser.setActive(filename) self.browser.scrollTo(filename) finally: self.browser.blockSignals(False) self._selectedStream = None for a in self.actGroupStream.actions(): logger.debug("Remove stream group action: %s", a.data()) self.actGroupStream.removeAction(a) for stream in streams: act = QAction(stream, self.actGroupStream) act.triggered.connect(lambda cstream=stream: self.setSelectedStream(cstream)) act.setCheckable(True) act.setChecked(False) logger.debug("Add stream group action: %s", act.data()) self.actGroupStreamMenu.addAction(act) QTimer.singleShot(250, self.scrollToCurrent) super()._sequenceOpened(filename, begin, end, streams) def _currentTimestampChanged(self, currentTime): """ Notifies about a changed timestamp :param currentTime: the new current timestamp :return: None """ assertMainThread() if self.beginTime is None: self.currentLabel.setText("") else: sliderVal = currentTime.toMSecsSinceEpoch() - self.beginTime.toMSecsSinceEpoch() self.preventSeek = True self.positionSlider.setValue(sliderVal) self.preventSeek = False self.positionSlider.blockSignals(False) self.currentLabel.setEnabled(True) self.currentLabel.setText(currentTime.toString("hh:mm:ss.zzz")) super()._currentTimestampChanged(currentTime) def onSliderValueChanged(self, value): """ Slot called whenever the slider value is changed. :param value: the new slider value :return: """ assertMainThread() if self.beginTime is None or self.preventSeek: return if self.actStart.isEnabled(): ts = QDateTime.fromMSecsSinceEpoch(self.beginTime.toMSecsSinceEpoch() + value, self.beginTime.timeSpec()) self._seekTime.emit(ts) else: logger.warning("Can't seek while playing.") def displayPosition(self, value): """ Slot called when the slider is moved. Displays the position without actually seeking to it. :param value: the new slider value. :return: """ assertMainThread() if self.beginTime is None: return if self.positionSlider.isSliderDown(): ts = QDateTime.fromMSecsSinceEpoch(self.beginTime.toMSecsSinceEpoch() + value, self.beginTime.timeSpec()) self.currentLabel.setEnabled(False) self.currentLabel.setText(ts.toString("hh:mm:ss.zzz")) def _playbackStarted(self): """ Notifies about starting playback :return: None """ assertMainThread() self.actStart.setEnabled(False) if "pausePlayback" in self.featureset: self.actPause.setEnabled(True) super()._playbackStarted() def _playbackPaused(self): """ Notifies about pause playback :return: None """ assertMainThread() logger.debug("playbackPaused received") if "startPlayback" in self.featureset: self.actStart.setEnabled(True) self.actPause.setEnabled(False) super()._playbackPaused() def openRecent(self): """ Called when the user clicks on a recent sequence. :return: """ assertMainThread() action = self.sender() self.browser.setActive(action.data()) def browserActivated(self, filename): """ Called when the user activated a file. :param filename: the new filename :return: """ assertMainThread() if filename is not None and Path(filename).is_file(): foundIdx = None for i, a in enumerate(self.recentSeqs): if a.data() == filename: foundIdx = i if foundIdx is None: foundIdx = len(self.recentSeqs)-1 for i in range(foundIdx, 0, -1): self.recentSeqs[i].setText(self.recentSeqs[i-1].text()) self.recentSeqs[i].setData(self.recentSeqs[i-1].data()) logger.debug("%d data: %s", i, self.recentSeqs[i-1].data()) self.recentSeqs[i].setVisible(self.recentSeqs[i-1].data() is not None) self.recentSeqs[0].setText(self.compressFileName(filename)) self.recentSeqs[0].setData(filename) self.recentSeqs[0].setVisible(True) self._setSequence.emit(filename) def _timeRatioChanged(self, newRatio): """ Notifies about a changed playback time ratio, :param newRatio the new playback ratio as a float :return: None """ assertMainThread() self.timeRatio = newRatio logger.debug("new timeRatio: %f", newRatio) for r in [1/8, 1/4, 1/2, 1, 2, 4, 8]: if abs(newRatio / r - 1) < 0.01: self.timeRatioLabel.setText(("x 1/%d"%(1/r)) if r < 1 else ("x %d"%r)) return self.timeRatioLabel.setText("%.2f" % newRatio) super()._timeRatioChanged(newRatio) def selectedStream(self): """ Returns the user-selected stream (for forward/backward stepping) :return: """ return self._selectedStream def setSelectedStream(self, stream): """ Sets the user-selected stream (for forward/backward stepping) :param stream the stream name. :return: """ self._selectedStream = stream def saveState(self): """ Saves the state of the playback control :return: """ assertMainThread() propertyCollection = self.config.guiState() showAllFiles = self.actShowAllFiles.isChecked() folder = self.browser.folder() logger.debug("Storing current folder: %s", folder) try: propertyCollection.setProperty("PlaybackControl_showAllFiles", int(showAllFiles)) propertyCollection.setProperty("PlaybackControl_folder", folder) recentFiles = [a.data() for a in self.recentSeqs if a.data() is not None] propertyCollection.setProperty("PlaybackControl_recent", "|".join(recentFiles)) except PropertyCollectionPropertyNotFound: pass def restoreState(self): """ Restores the state of the playback control from the given property collection :param propertyCollection: a PropertyCollection instance :return: """ assertMainThread() propertyCollection = self.config.guiState() propertyCollection.defineProperty("PlaybackControl_showAllFiles", 0, "show all files setting") showAllFiles = propertyCollection.getProperty("PlaybackControl_showAllFiles") self.actShowAllFiles.setChecked(bool(showAllFiles)) propertyCollection.defineProperty("PlaybackControl_folder", "", "current folder name") folder = propertyCollection.getProperty("PlaybackControl_folder") if Path(folder).is_dir(): logger.debug("Setting current file: %s", folder) self.browser.setFolder(folder) propertyCollection.defineProperty("PlaybackControl_recent", "", "recent opened sequences") recentFiles = propertyCollection.getProperty("PlaybackControl_recent") idx = 0 for f in recentFiles.split("|"): if f != "" and Path(f).is_file(): self.recentSeqs[idx].setData(f) self.recentSeqs[idx].setText(self.compressFileName(f)) self.recentSeqs[idx].setVisible(True) idx += 1 if idx >= len(self.recentSeqs): break for a in self.recentSeqs[idx:]: a.setData(None) a.setText("") a.setVisible(False) @staticmethod def compressFileName(filename): """ Compresses long path names with an ellipsis (...) :param filename: the original path name as a Path or string instance :return: the compressed path name as a string instance """ p = Path(filename) parts = tuple(p.parts) if len(parts) >= 6: p = Path(*parts[:2]) / "..." / Path(*parts[-2:]) return str(p)
class MVCRecordingControlGUI(MVCRecordingControlBase): """ This service implements a GUI frontend for the recording service """ def __init__(self, config): assertMainThread() super().__init__(config) # state self._directory = str(Path('.').absolute()) # gui srv = Services.getService("MainWindow") config.configLoaded.connect(self._restoreState) config.configAboutToSave.connect(self._saveState) self._config = config recMenu = srv.menuBar().addMenu("&Recording") style = QApplication.style() self.actStart = QAction( QIcon.fromTheme("media-record", QIcon(":icons/media-record.svg")), "Start Recording", self) self.actStop = QAction( QIcon.fromTheme("media-playback-stop", style.standardIcon(QStyle.SP_MediaStop)), "Stop Recording", self) self.actSetDir = QAction( QIcon.fromTheme("document-open-folder", style.standardIcon(QStyle.SP_DirIcon)), "Choose directory ...", self) self.actStart.setEnabled(False) self.actStop.setEnabled(False) self.actSetDir.setEnabled(False) self.actStart.triggered.connect(self._startTriggered) self.actStop.triggered.connect(self._stopTriggered) self.actSetDir.triggered.connect(self._setDir) recMenu.addAction(self.actStart) recMenu.addAction(self.actStop) recMenu.addAction(self.actSetDir) self.dockWidget = srv.newDockWidget("RecordingControl", None, Qt.LeftDockWidgetArea, defaultLoc="PlaybackControl") self.dockWidgetContents = QWidget(self.dockWidget) self.dockWidget.setWidget(self.dockWidgetContents) toolLayout = QBoxLayout(QBoxLayout.TopToBottom, self.dockWidgetContents) toolLayout.setContentsMargins(0, 0, 0, 0) toolBar = QToolBar() toolLayout.addWidget(toolBar) toolBar.addAction(self.actStart) toolBar.addAction(self.actStop) toolBar.addAction(self.actSetDir) self._directoryLabel = ElidedLabel(self._directory, parent=self.dockWidgetContents) to = self._directoryLabel.textOption() to.setWrapMode(QTextOption.NoWrap) self._directoryLabel.setTextOption(to) self._directoryLabel.setElideMode(Qt.ElideMiddle) self._statusLabel = ElidedLabel("(disabled)", parent=self.dockWidgetContents) to = self._statusLabel.textOption() to.setWrapMode(QTextOption.NoWrap) self._statusLabel.setTextOption(to) self._statusLabel.setElideMode(Qt.ElideMiddle) toolLayout.addWidget(self._directoryLabel) toolLayout.addWidget(self._statusLabel, stretch=100) #toolLayout.addStretch(100) self.statusUpdate.connect(self._onUpdateStatus) self.notifyError.connect(self._onNotifyError) def _startTriggered(self): self.startRecording(self._directory) self.actStart.setEnabled(False) self.actStop.setEnabled(True) def _stopTriggered(self): self.stopRecording() self.actStart.setEnabled(True) self.actStop.setEnabled(False) def _setDir(self): tdir = QFileDialog.getExistingDirectory( parent=self.dockWidget, caption="Select recording target directory", dir=self._directory) if tdir != "" and tdir is not None: self._directory = str(Path(tdir).absolute()) self._directoryLabel.setText(self._directory) def _supportedFeaturesChanged(self, featureset): if len(featureset) > 0 and not self.actSetDir.isEnabled(): self.actStart.setEnabled(True) self.actStop.setEnabled(False) self.actSetDir.setEnabled(True) self._statusLabel.setText("inactive") elif len(featureset) == 0 and self.actSetDir.isEnabled(): self.actStart.setEnabled(False) self.actStop.setEnabled(False) self.actSetDir.setEnabled(False) self._statusLabel.setText("(disabled)") def _onUpdateStatus(self, _, file, length, bytesWritten): lines = self._statusLabel.text().split("\n") if length < 0: length = None if bytesWritten < 0: bytesWritten = None updated = False if bytesWritten is None: bw = "??" elif bytesWritten < 1024: bw = "%3d bytes" % bytesWritten elif bytesWritten < 1024 * 1024: bw = "%.1f kb" % (bytesWritten / 1024) elif bytesWritten < 1024 * 1024 * 1024: bw = "%.1f Mb" % (bytesWritten / 1024 / 1024) else: bw = "%.1f Gb" % (bytesWritten / 1024 / 1024 / 1024) if length is None: sl = "?? s" elif length < 60: sl = "%.1f sec" % length else: sl = "%.1f min" % (length // 60) bytesAv = QStorageInfo(file).bytesAvailable() if length is not None and bytesWritten is not None and bytesAv >= 0 and bytesWritten > 0: timeAv = length * bytesAv / bytesWritten - length if timeAv < 60: av = "%.1f sec" % timeAv elif timeAv < 3600: av = "%.1f min" % (timeAv // 60) else: av = "> 1 hour" else: av = "?? s" if length is not None or bytesWritten is not None: newl = Path(file).name + ": " + sl + " | " + bw + " R: " + av else: newl = None if newl is not None: for i, l in enumerate(lines): if l.startswith(Path(file).name + ":"): updated = True lines[i] = newl break if not updated: lines.append(newl) if lines[0] == "inactive": lines = lines[1:] else: toDel = None for i, l in enumerate(lines): if l.startswith(Path(file).name + ":"): toDel = i break if toDel is not None: lines = lines[:toDel] + lines[toDel + 1:] if len(lines) == 0: lines.append("inactive") self._statusLabel.setText("\n".join(lines)) def _onNotifyError(self, originFilter, errorDesc): lines = self._statusLabel.text().split("\n") newl = originFilter.objectName() + ": " + "ERROR: " + errorDesc updated = False for i, l in enumerate(lines): if l.startswith(originFilter.objectName() + ":"): updated = True lines[i] = newl break if not updated: lines.append(newl) if lines[0] == "inactive": lines = lines[1:] self._statusLabel.setText("\n".join(lines)) def _defineProperties(self): propertyCollection = self._config.guiState() propertyCollection.defineProperty("RecordingControl_directory", str(Path('.').absolute()), "Target directory for recordings") def _saveState(self): """ Saves the state of the playback control :return: """ assertMainThread() self._defineProperties() propertyCollection = self._config.guiState() try: propertyCollection.setProperty("RecordingControl_directory", self._directory) except PropertyCollectionPropertyNotFound: pass def _restoreState(self): """ Restores the state of the playback control from the given property collection :return: """ assertMainThread() self._defineProperties() propertyCollection = self._config.guiState() logger.debug("before restore dir=%s", self._directory) d = propertyCollection.getProperty("RecordingControl_directory") if Path(d).exists(): self._directory = d self._directoryLabel.setText(self._directory) logger.debug("after restore dir=%s", self._directory)