diff --git a/mycroft/enclosure/gui.py b/mycroft/enclosure/gui.py index 33130bca7961..df0492e47fd9 100644 --- a/mycroft/enclosure/gui.py +++ b/mycroft/enclosure/gui.py @@ -14,12 +14,19 @@ # """ Interface for interacting with the Mycroft gui qml viewer. """ from os.path import join - +from enum import IntEnum from mycroft.configuration import Configuration from mycroft.messagebus.message import Message from mycroft.util import resolve_resource_file +class GUIPlaybackStatus(IntEnum): + STOPPED = 0 + PLAYING = 1 + PAUSED = 2 + UNDEFINED = 3 + + class SkillGUI: """SkillGUI - Interface to the Graphical User Interface @@ -38,6 +45,7 @@ def __init__(self, skill): self.skill = skill self.on_gui_changed_callback = None self.config = Configuration.get() + self.video_info = None @property def connected(self): @@ -63,6 +71,42 @@ def setup_default_handlers(self): msg_type = self.build_message_type('set') self.skill.add_event(msg_type, self.gui_set) + # TODO can we rename this namespace to mycroft.playback.XXX ? + self.skill.add_event('mycroft.audio.service.pause', + self.__handle_gui_pause) + self.skill.add_event('mycroft.audio.service.resume', + self.__handle_gui_resume) + self.skill.add_event('mycroft.audio.service.stop', + self.__handle_gui_stop) + self.skill.add_event('mycroft.audio.service.track_info', + self.__handle_gui_track_info) + self.skill.add_event('mycroft.audio.queue_end', + self.__handle_gui_stop) + self.skill.gui.register_handler('video.media.playback.ended', + self.__handle_gui_stop) + + # Audio Service bus messages + def __handle_gui_resume(self, message): + """Resume video playback in gui""" + self.resume_video() + + def __handle_gui_stop(self, message): + """Stop video playback in gui""" + self.stop_video() + + def __handle_gui_pause(self, message): + """Pause video playback in gui""" + self.pause_video() + + def __handle_gui_track_info(self, message): + """Answer request information of current playing track. + Needed for handling stop """ + if self.video_info: + self.skill.bus.emit( + message.reply('mycroft.audio.service.track_info_reply', + self.video_info)) + return self.video_info + def register_handler(self, event, handler): """Register a handler for GUI events. @@ -367,6 +411,72 @@ def release(self): self.skill.bus.emit(Message("mycroft.gui.screen.close", {"skill_id": self.skill.skill_id})) + def play_video(self, url, title="", repeat=None, override_idle=True, + override_animations=None): + """ Play video stream + + Arguments: + url (str): URL of video source + title (str): Title of media to be displayed + repeat (boolean, int): + True: Infinitly loops the current video track + (int): Loops the video track for specified number of + times. + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self["playStatus"] = "play" + self["video"] = url + self["title"] = title + self["playerRepeat"] = repeat + self.video_info = {"title": title, "url": url} + self.show_page("SYSTEM_VideoPlayer.qml", + override_idle=override_idle, + override_animations=override_animations) + + @property + def is_video_displayed(self): + """Returns whether the gui is in a video playback state. + Eg if the video is paused, it would still be displayed on screen + but the video itself is not "playing" so to speak""" + return self.video_info is not None + + @property + def playback_status(self): + """Returns gui playback status, + indicates if gui is playing, paused or stopped""" + if self.__session_data.get("playStatus", -1) == "play": + return GUIPlaybackStatus.PLAYING + if self.__session_data.get("playStatus", -1) == "pause": + return GUIPlaybackStatus.PAUSED + if self.__session_data.get("playStatus", -1) == "stop": + return GUIPlaybackStatus.STOPPED + return GUIPlaybackStatus.UNDEFINED + + def pause_video(self): + """Pause video playback.""" + if self.is_video_displayed: + self["playStatus"] = "pause" + + def stop_video(self): + """Stop video playback.""" + # TODO detect end of media playback from gui and call this + if self.is_video_displayed: + self["playStatus"] = "stop" + self.skill.bus.emit(Message("mycroft.gui.screen.close", + {"skill_id": self.skill.skill_id})) + self.video_info = None + + def resume_video(self): + """Resume paused video playback.""" + if self.__session_data.get("playStatus", "stop") == "pause": + self["playStatus"] = "play" + def shutdown(self): """Shutdown gui interface. diff --git a/mycroft/res/ui/SYSTEM_VideoPlayer.qml b/mycroft/res/ui/SYSTEM_VideoPlayer.qml new file mode 100644 index 000000000000..b4cdb6dfe6f4 --- /dev/null +++ b/mycroft/res/ui/SYSTEM_VideoPlayer.qml @@ -0,0 +1,225 @@ +import QtMultimedia 5.12 +import QtQuick.Layouts 1.4 +import QtQuick 2.9 +import QtQuick.Controls 2.12 as Controls +import org.kde.kirigami 2.10 as Kirigami +import QtQuick.Window 2.3 +import QtGraphicalEffects 1.0 +import Mycroft 1.0 as Mycroft +import "." as Local + +Mycroft.Delegate { + id: root + + property var videoSource: sessionData.video + property var videoStatus: sessionData.playStatus + property var videoRepeat: sessionData.playerRepeat + property var videoThumb: sessionData.videoThumb + property var videoTitle: sessionData.title + property bool busyIndicate: false + + fillWidth: true + background: Rectangle { + color: "black" + } + leftPadding: 0 + topPadding: 0 + rightPadding: 0 + bottomPadding: 0 + + onEnabledChanged: syncStatusTimer.restart() + onVideoSourceChanged: syncStatusTimer.restart() + + Component.onCompleted: { + syncStatusTimer.restart() + } + + Keys.onDownPressed: { + controlBarItem.opened = true + controlBarItem.forceActiveFocus() + } + + onFocusChanged: { + video.forceActiveFocus(); + } + + onVideoRepeatChanged: { + if(typeof videoRepeat !== "undefined" && typeof videoRepeat == "boolean"){ + if(videoRepeat){ + video.loops = MediaPlayer.Infinite + video.flushMode = VideoOutput.LastFrame + } + } else if(typeof videoRepeat !== "undefined" && typeof videoRepeat == "number"){ + if(videoRepeat > 1){ + video.loops = videoRepeat + video.flushMode = VideoOutput.LastFrame + } + } else { + video.loops = 1 + video.flushMode = VideoOutput.EmptyFrame + } + } + + onVideoStatusChanged: { + switch(videoStatus){ + case "stop": + video.stop(); + break; + case "pause": + video.pause() + break; + case "play": + video.play() + delay(6000, function() { + infomationBar.visible = false; + }) + break; + } + } + + Connections { + target: Window.window + onVisibleChanged: { + if(video.playbackState == MediaPlayer.PlayingState) { + video.stop() + } + } + } + + Timer { + id: syncStatusTimer + interval: 0 + onTriggered: { + if (enabled && videoStatus == "play") { + video.play(); + } else if (videoStatus == "stop") { + video.stop(); + } else { + video.pause(); + } + } + } + + Timer { + id: delaytimer + } + + function delay(delayTime, cb) { + delaytimer.interval = delayTime; + delaytimer.repeat = false; + delaytimer.triggered.connect(cb); + delaytimer.start(); + } + + controlBar: Local.SeekControl { + id: seekControl + anchors { + bottom: parent.bottom + } + title: videoTitle + videoControl: video + duration: video.duration + playPosition: video.position + onSeekPositionChanged: video.seek(seekPosition); + z: 1000 + } + + Item { + id: videoRoot + anchors.fill: parent + + Rectangle { + id: infomationBar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + visible: false + color: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.6) + implicitHeight: vidTitle.implicitHeight + Kirigami.Units.largeSpacing * 2 + z: 1001 + + onVisibleChanged: { + delay(15000, function() { + infomationBar.visible = false; + }) + } + + Controls.Label { + id: vidTitle + visible: true + maximumLineCount: 2 + wrapMode: Text.Wrap + anchors.left: parent.left + anchors.leftMargin: Kirigami.Units.largeSpacing + anchors.verticalCenter: parent.verticalCenter + text: videoTitle + z: 100 + } + } + + Video { + id: video + anchors.fill: parent + focus: true + autoLoad: true + autoPlay: false + loops: 1 + source: videoSource + + Keys.onReturnPressed: { + video.playbackState == MediaPlayer.PlayingState ? video.pause() : video.play() + } + + Keys.onDownPressed: { + controlBarItem.opened = true + controlBarItem.forceActiveFocus() + } + + MouseArea { + anchors.fill: parent + onClicked: { + controlBarItem.opened = !controlBarItem.opened + } + } + + onStatusChanged: { + console.log(status) + if(status == MediaPlayer.EndOfMedia) { + triggerGuiEvent("video.media.playback.ended", {}) + busyIndicatorPop.enabled = false + } + if(status == MediaPlayer.Loading) { + busyIndicatorPop.visible = true + busyIndicatorPop.enabled = true + } + if(status == MediaPlayer.Loaded || status == MediaPlayer.Buffered){ + busyIndicatorPop.visible = false + busyIndicatorPop.enabled = false + } + } + + Rectangle { + id: busyIndicatorPop + width: parent.width + height: parent.height + color: Qt.rgba(0, 0, 0, 0.2) + visible: false + enabled: false + + Controls.BusyIndicator { + id: busyIndicate + running: busyIndicate + anchors.centerIn: parent + } + + onEnabledChanged: { + if(busyIndicatorPop.enabled){ + busyIndicate.running = true + } else { + busyIndicate.running = false + } + } + } + } + } +} diff --git a/mycroft/res/ui/SeekControl.qml b/mycroft/res/ui/SeekControl.qml new file mode 100644 index 000000000000..30c48bc2492c --- /dev/null +++ b/mycroft/res/ui/SeekControl.qml @@ -0,0 +1,271 @@ +import QtMultimedia 5.12 +import QtQuick.Layouts 1.4 +import QtQuick 2.9 +import QtQuick.Controls 2.12 as Controls +import org.kde.kirigami 2.10 as Kirigami +import QtQuick.Templates 2.2 as Templates +import QtGraphicalEffects 1.0 + +import Mycroft 1.0 as Mycroft + +Item { + id: seekControl + property bool opened: false + property int duration: 0 + property int playPosition: 0 + property int seekPosition: 0 + property bool enabled: true + property bool seeking: false + property var videoControl + property string title + + clip: true + implicitWidth: parent.width + implicitHeight: mainLayout.implicitHeight + Kirigami.Units.largeSpacing * 2 + opacity: opened + + onOpenedChanged: { + if (opened) { + hideTimer.restart(); + } + } + + onFocusChanged: { + if(focus) { + backButton.forceActiveFocus() + } + } + + Timer { + id: hideTimer + interval: 5000 + onTriggered: { + seekControl.opened = false; + videoRoot.forceActiveFocus(); + } + } + + Rectangle { + width: parent.width + height: parent.height + color: Qt.rgba(0, 0, 0, 0.8) + y: opened ? 0 : parent.height + + ColumnLayout { + id: mainLayout + + anchors { + fill: parent + margins: Kirigami.Units.largeSpacing + } + + RowLayout { + id: mainLayout2 + Layout.fillHeight: true + Controls.RoundButton { + id: backButton + Layout.preferredWidth: parent.width > 600 ? Kirigami.Units.iconSizes.large : Kirigami.Units.iconSizes.medium + Layout.preferredHeight: Layout.preferredWidth + highlighted: focus ? 1 : 0 + z: 1000 + + background: Rectangle { + radius: 200 + color: "#1a1a1a" + border.width: 1.25 + border.color: "white" + } + + contentItem: Item { + Image { + width: parent.width - Kirigami.Units.largeSpacing + height: width + anchors.centerIn: parent + source: "images/back.svg" + } + } + + onClicked: { + triggerGuiEvent("video.media.playback.ended", {}) + video.stop(); + } + KeyNavigation.up: video + KeyNavigation.right: button + Keys.onReturnPressed: { + hideTimer.restart(); + triggerGuiEvent("video.media.playback.ended", {}) + video.stop(); + } + onFocusChanged: { + hideTimer.restart(); + } + } + Controls.RoundButton { + id: button + Layout.preferredWidth: parent.width > 600 ? Kirigami.Units.iconSizes.large : Kirigami.Units.iconSizes.medium + Layout.preferredHeight: Layout.preferredWidth + highlighted: focus ? 1 : 0 + z: 1000 + + background: Rectangle { + radius: 200 + color: "#1a1a1a" + border.width: 1.25 + border.color: "white" + } + + contentItem: Item { + Image { + width: parent.width - Kirigami.Units.largeSpacing + height: width + anchors.centerIn: parent + source: videoControl.playbackState === MediaPlayer.PlayingState ? "images/media-pause.svg" : "images/media-play.svg" + } + } + + onClicked: { + video.playbackState === MediaPlayer.PlayingState ? video.pause() : video.play(); + hideTimer.restart(); + } + KeyNavigation.up: video + KeyNavigation.left: backButton + KeyNavigation.right: slider + Keys.onReturnPressed: { + video.playbackState === MediaPlayer.PlayingState ? video.pause() : video.play(); + hideTimer.restart(); + } + onFocusChanged: { + hideTimer.restart(); + } + } + + Templates.Slider { + id: slider + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + implicitHeight: Kirigami.Units.gridUnit + value: seekControl.playPosition + from: 0 + to: seekControl.duration + z: 1000 + property bool navSliderItem + property int minimumValue: 0 + property int maximumValue: 20 + onMoved: { + seekControl.seekPosition = value; + hideTimer.restart(); + } + + onNavSliderItemChanged: { + if(slider.navSliderItem){ + recthandler.color = "red" + } else if (slider.focus) { + recthandler.color = Kirigami.Theme.linkColor + } + } + + onFocusChanged: { + if(!slider.focus){ + recthandler.color = Kirigami.Theme.textColor + } else { + recthandler.color = Kirigami.Theme.linkColor + } + } + + handle: Rectangle { + id: recthandler + x: slider.position * (parent.width - width) + implicitWidth: Kirigami.Units.gridUnit + implicitHeight: implicitWidth + radius: width + color: Kirigami.Theme.textColor + } + background: Item { + Rectangle { + id: groove + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + } + radius: height + height: Math.round(Kirigami.Units.gridUnit/3) + color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.3) + Rectangle { + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + radius: height + color: Kirigami.Theme.highlightColor + width: slider.position * (parent.width - slider.handle.width/2) + slider.handle.width/2 + } + } + + Controls.Label { + anchors { + left: parent.left + top: groove.bottom + topMargin: Kirigami.Units.smallSpacing + } + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + text: formatTime(playPosition) + color: "white" + } + + Controls.Label { + anchors { + right: parent.right + top: groove.bottom + topMargin: Kirigami.Units.smallSpacing + } + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + text: formatTime(duration) + } + } + KeyNavigation.up: video + KeyNavigation.left: button + Keys.onReturnPressed: { + hideTimer.restart(); + if(!navSliderItem){ + navSliderItem = true + } else { + navSliderItem = false + } + } + + Keys.onLeftPressed: { + console.log("leftPressedonSlider") + hideTimer.restart(); + if(navSliderItem) { + video.seek(video.position - 5000) + } else { + button.forceActiveFocus() + } + } + + Keys.onRightPressed: { + hideTimer.restart(); + if(navSliderItem) { + video.seek(video.position + 5000) + } + } + } + + } + } + } + + + function formatTime(timeInMs) { + if (!timeInMs || timeInMs <= 0) return "0:00" + var seconds = timeInMs / 1000; + var minutes = Math.floor(seconds / 60) + seconds = Math.floor(seconds % 60) + if (seconds < 10) seconds = "0" + seconds; + return minutes + ":" + seconds + } +} diff --git a/mycroft/res/ui/images/back.svg b/mycroft/res/ui/images/back.svg new file mode 100644 index 000000000000..eaf775bac2fc --- /dev/null +++ b/mycroft/res/ui/images/back.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-fullscreen.svg b/mycroft/res/ui/images/media-fullscreen.svg new file mode 100644 index 000000000000..0ce22138e575 --- /dev/null +++ b/mycroft/res/ui/images/media-fullscreen.svg @@ -0,0 +1,135 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-mute.svg b/mycroft/res/ui/images/media-mute.svg new file mode 100644 index 000000000000..fac83baa966c --- /dev/null +++ b/mycroft/res/ui/images/media-mute.svg @@ -0,0 +1,130 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-next.svg b/mycroft/res/ui/images/media-next.svg new file mode 100644 index 000000000000..1c5a5262f8d4 --- /dev/null +++ b/mycroft/res/ui/images/media-next.svg @@ -0,0 +1,96 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-pause.svg b/mycroft/res/ui/images/media-pause.svg new file mode 100644 index 000000000000..06d0241fa527 --- /dev/null +++ b/mycroft/res/ui/images/media-pause.svg @@ -0,0 +1,95 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-play.svg b/mycroft/res/ui/images/media-play.svg new file mode 100644 index 000000000000..ab08c7cc6253 --- /dev/null +++ b/mycroft/res/ui/images/media-play.svg @@ -0,0 +1,86 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-playback-pause.svg b/mycroft/res/ui/images/media-playback-pause.svg new file mode 100644 index 000000000000..972dfa2f09d8 --- /dev/null +++ b/mycroft/res/ui/images/media-playback-pause.svg @@ -0,0 +1,61 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-playback-start.svg b/mycroft/res/ui/images/media-playback-start.svg new file mode 100644 index 000000000000..4627f98824b3 --- /dev/null +++ b/mycroft/res/ui/images/media-playback-start.svg @@ -0,0 +1,61 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-previous.svg b/mycroft/res/ui/images/media-previous.svg new file mode 100644 index 000000000000..1dc4d094e5ab --- /dev/null +++ b/mycroft/res/ui/images/media-previous.svg @@ -0,0 +1,96 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-repeat.svg b/mycroft/res/ui/images/media-repeat.svg new file mode 100644 index 000000000000..ea57dd894ae3 --- /dev/null +++ b/mycroft/res/ui/images/media-repeat.svg @@ -0,0 +1,154 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-stop.svg b/mycroft/res/ui/images/media-stop.svg new file mode 100644 index 000000000000..ae86a1951f6f --- /dev/null +++ b/mycroft/res/ui/images/media-stop.svg @@ -0,0 +1,87 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/mycroft/res/ui/images/media-unmute.svg b/mycroft/res/ui/images/media-unmute.svg new file mode 100644 index 000000000000..6988c01636f9 --- /dev/null +++ b/mycroft/res/ui/images/media-unmute.svg @@ -0,0 +1,137 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/test/unittests/enclosure/test_gui.py b/test/unittests/enclosure/test_gui.py index 9af11e4f4285..c46dc32a85f2 100644 --- a/test/unittests/enclosure/test_gui.py +++ b/test/unittests/enclosure/test_gui.py @@ -16,7 +16,7 @@ from unittest import TestCase, mock -from mycroft.enclosure.gui import SkillGUI +from mycroft.enclosure.gui import SkillGUI, GUIPlaybackStatus from mycroft.messagebus import Message from mycroft.util.file_utils import resolve_resource_file @@ -106,6 +106,29 @@ def test_show_html(self): self.assertEqual(sent_message.data['page'], [page_url]) self.assertEqual(self.gui['html'], html) + def test_video_playback(self): + self.assertEqual(self.gui.playback_status, GUIPlaybackStatus.UNDEFINED) + self.assertEqual(self.gui.is_video_displayed, False) + video_url = "https://downloads.mycroft.ai/assets/tests/test.mp4" + video_title = "Logo animation" + self.gui.play_video(video_url, video_title) + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + page_path = resolve_resource_file('ui/SYSTEM_VideoPlayer.qml') + page_url = 'file://{}'.format(page_path) + self.assertEqual(sent_message.data['page'], [page_url]) + self.assertEqual(self.gui['video'], video_url) + self.assertEqual(self.gui['title'], video_title) + self.assertEqual(self.gui.get('repeat'), None) + self.assertEqual(self.gui.is_video_displayed, True) + self.assertEqual(self.gui.playback_status, GUIPlaybackStatus.PLAYING) + self.gui.pause_video() + self.assertEqual(self.gui.playback_status, GUIPlaybackStatus.PAUSED) + self.gui.resume_video() + self.assertEqual(self.gui.playback_status, GUIPlaybackStatus.PLAYING) + self.gui.stop_video() + self.assertEqual(self.gui.playback_status, GUIPlaybackStatus.STOPPED) + self.assertEqual(self.gui.is_video_displayed, False) + def test_send_event(self): """Check that send_event sends message using the correct format.""" params = 'Not again'