diff --git a/examples/cps_tracker.py b/examples/cps_tracker.py new file mode 100644 index 0000000..bfdddcb --- /dev/null +++ b/examples/cps_tracker.py @@ -0,0 +1,7 @@ +from ovos_utils import wait_for_exit_signal +from ovos_utils.playback.cps import CPSTracker + + +g = CPSTracker() + +wait_for_exit_signal() diff --git a/ovos_utils/gui.py b/ovos_utils/gui.py index c5211d4..0c44c70 100644 --- a/ovos_utils/gui.py +++ b/ovos_utils/gui.py @@ -1,8 +1,11 @@ from ovos_utils.system import is_installed, has_screen +from ovos_utils import resolve_ovos_resource_file, resolve_resource_file from ovos_utils.messagebus import wait_for_reply, get_mycroft_bus, Message from ovos_utils.log import LOG from collections import namedtuple import time +from os.path import join +from enum import IntEnum def can_display(): @@ -24,6 +27,13 @@ def is_gui_connected(bus=None): return False +class GUIPlaybackStatus(IntEnum): + STOPPED = 0 + PLAYING = 1 + PAUSED = 2 + UNDEFINED = 3 + + class GUITracker: """ Replicates GUI API from mycroft-core, does not interact with GUI but exactly mimics status""" @@ -268,7 +278,8 @@ def _show(self, namespace, page, index): self.__move_namespace(index, 0) # Find if any new pages needs to be inserted - new_pages = [p for p in pages if p not in self._loaded[0].pages] + new_pages = [p for p in pages if + p not in self._loaded[0].pages] if new_pages: self.__insert_pages(namespace, new_pages) except Exception as e: @@ -369,8 +380,447 @@ def _on_show_idle(self, message): self._is_idle = True +class GUIInterface: + """Interface to the Graphical User Interface, allows interaction with + the mycroft-gui from anywhere + + Values set in this class are synced to the GUI, accessible within QML + via the built-in sessionData mechanism. For example, in Python you can + write in a skill: + self.gui['temp'] = 33 + self.gui.show_page('Weather.qml') + Then in the Weather.qml you'd access the temp via code such as: + text: sessionData.time + """ + + def __init__(self, skill_id, bus=None, remote_server=None): + self.bus = bus or get_mycroft_bus() + self.__session_data = {} # synced to GUI for use by this skill's pages + self.page = None # the active GUI page (e.g. QML template) to show + self.skill_id = skill_id + self.on_gui_changed_callback = None + self.remote_url = remote_server + self._events = [] + self.video_info = None + self.setup_default_handlers() + + @property + def connected(self): + """Returns True if at least 1 gui is connected, else False""" + return is_gui_connected(self.bus) + + # events + def setup_default_handlers(self): + """Sets the handlers for the default messages.""" + self.register_handler('set', self.handle_gui_set) + # should be emitted by self.play_video + self.register_handler('playback.ended', + self.handle_gui_stop) + + def register_handler(self, event, handler): + """Register a handler for GUI events. + + will be prepended with self.skill_id.XXX if missing in event + + When using the triggerEvent method from Qt + triggerEvent("event", {"data": "cool"}) + + Arguments: + event (str): event to catch + handler: function to handle the event + """ + if not event.startswith(f'{self.skill_id}.'): + event = f'{self.skill_id}.' + event + self._events.append((event, handler)) + self.bus.on(event, handler) + + def set_on_gui_changed(self, callback): + """Registers a callback function to run when a value is + changed from the GUI. + + Arguments: + callback: Function to call when a value is changed + """ + self.on_gui_changed_callback = callback + + def send_event(self, event_name, params=None): + """Trigger a gui event. + + Arguments: + event_name (str): name of event to be triggered + params: json serializable object containing any parameters that + should be sent along with the request. + """ + params = params or {} + self.bus.emit(Message("gui.event.send", + {"__from": self.skill_id, + "event_name": event_name, + "params": params})) + + # internals + def handle_gui_stop(self, message): + """Stop video playback in gui""" + self.stop_video() + + def handle_gui_set(self, message): + """Handler catching variable changes from the GUI. + + Arguments: + message: Messagebus message + """ + for key in message.data: + self[key] = message.data[key] + if self.on_gui_changed_callback: + self.on_gui_changed_callback() + + def __setitem__(self, key, value): + """Implements set part of dict-like behaviour with named keys.""" + self.__session_data[key] = value + + if self.page: + # emit notification (but not needed if page has not been shown yet) + data = self.__session_data.copy() + data.update({'__from': self.skill_id}) + self.bus.emit(Message("gui.value.set", data)) + + def __getitem__(self, key): + """Implements get part of dict-like behaviour with named keys.""" + return self.__session_data[key] + + def get(self, *args, **kwargs): + """Implements the get method for accessing dict keys.""" + return self.__session_data.get(*args, **kwargs) + + def __contains__(self, key): + """Implements the "in" operation.""" + return self.__session_data.__contains__(key) + + def _pages2uri(self, page_names): + # Convert pages to full reference + page_urls = [] + for name in page_names: + page = resolve_resource_file(name) or \ + resolve_resource_file(join('ui', name)) or \ + resolve_ovos_resource_file(name) or \ + resolve_ovos_resource_file(join('ui', name)) + + if page: + if self.remote_url: + page_urls.append(self.remote_url + "/" + page) + elif page.startswith("file://"): + page_urls.append(page) + else: + page_urls.append("file://" + page) + else: + LOG.error("Unable to find page: {}".format(name)) + return page_urls + + def shutdown(self): + """Shutdown gui interface. + + Clear pages loaded through this interface and remove the bus events + """ + self.release() + self.video_info = None + for event, handler in self._events: + self.bus.remove(event, handler) + + # base gui interactions + def show_page(self, name, override_idle=None, + override_animations=False): + """Begin showing the page in the GUI + + Arguments: + name (str): Name of page (e.g "mypage.qml") to display + 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.show_pages([name], 0, override_idle, override_animations) + + def show_pages(self, page_names, index=0, override_idle=None, + override_animations=False): + """Begin showing the list of pages in the GUI. + + Arguments: + page_names (list): List of page names (str) to display, such as + ["Weather.qml", "Forecast.qml", "Details.qml"] + index (int): Page number (0-based) to show initially. For the + above list a value of 1 would start on "Forecast.qml" + 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. + """ + if not isinstance(page_names, list): + raise ValueError('page_names must be a list') + + if index > len(page_names): + raise ValueError('Default index is larger than page list length') + + self.page = page_names[index] + + # First sync any data... + data = self.__session_data.copy() + data.update({'__from': self.skill_id}) + self.bus.emit(Message("gui.value.set", data)) + page_urls = self._pages2uri(page_names) + self.bus.emit(Message("gui.page.show", + {"page": page_urls, + "index": index, + "__from": self.skill_id, + "__idle": override_idle, + "__animations": override_animations})) + + def remove_page(self, page): + """Remove a single page from the GUI. + + Arguments: + page (str): Page to remove from the GUI + """ + return self.remove_pages([page]) + + def remove_pages(self, page_names): + """Remove a list of pages in the GUI. + + Arguments: + page_names (list): List of page names (str) to display, such as + ["Weather.qml", "Forecast.qml", "Other.qml"] + """ + if not isinstance(page_names, list): + page_names = [page_names] + page_urls = self._pages2uri(page_names) + self.bus.emit(Message("gui.page.delete", + {"page": page_urls, + "__from": self.skill_id})) + + def clear(self): + """Reset the value dictionary, and remove namespace from GUI. + + This method does not close the GUI for a Skill. For this purpose see + the `release` method. + """ + self.__session_data = {} + self.page = None + self.bus.emit(Message("gui.clear.namespace", + {"__from": self.skill_id})) + + def release(self): + """Signal that this skill is no longer using the GUI, + allow different platforms to properly handle this event. + Also calls self.clear() to reset the state variables + Platforms can close the window or go back to previous page""" + self.clear() + self.bus.emit(Message("mycroft.gui.screen.close", + {"skill_id": self.skill_id})) + + # Utils / Templates + def show_text(self, text, title=None, override_idle=None, + override_animations=False): + """Display a GUI page for viewing simple text. + + Arguments: + text (str): Main text content. It will auto-paginate + title (str): A title to display above the text content. + 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["text"] = text + self["title"] = title + self.show_page("SYSTEM_TextFrame.qml", override_idle, + override_animations) + + def show_image(self, url, caption=None, + title=None, fill=None, + override_idle=None, override_animations=False): + """Display a GUI page for viewing an image. + + Arguments: + url (str): Pointer to the image + caption (str): A caption to show under the image + title (str): A title to display above the image content + fill (str): Fill type supports 'PreserveAspectFit', + 'PreserveAspectCrop', 'Stretch' + 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["image"] = url + self["title"] = title + self["caption"] = caption + self["fill"] = fill + self.show_page("SYSTEM_ImageFrame.qml", override_idle, + override_animations) + + def show_animated_image(self, url, caption=None, + title=None, fill=None, + override_idle=None, override_animations=False): + """Display a GUI page for viewing an image. + + Arguments: + url (str): Pointer to the .gif image + caption (str): A caption to show under the image + title (str): A title to display above the image content + fill (str): Fill type supports 'PreserveAspectFit', + 'PreserveAspectCrop', 'Stretch' + 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["image"] = url + self["title"] = title + self["caption"] = caption + self["fill"] = fill + self.show_page("SYSTEM_AnimatedImageFrame.qml", override_idle, + override_animations) + + def show_html(self, html, resource_url=None, override_idle=None, + override_animations=False): + """Display an HTML page in the GUI. + + Arguments: + html (str): HTML text to display + resource_url (str): Pointer to HTML resources + 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["html"] = html + self["resourceLocation"] = resource_url + self.show_page("SYSTEM_HtmlFrame.qml", override_idle, + override_animations) + + def show_url(self, url, override_idle=None, + override_animations=False): + """Display an HTML page in the GUI. + + Arguments: + url (str): URL to render + 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["url"] = url + self.show_page("SYSTEM_UrlFrame.qml", override_idle, + override_animations) + + def show_confirmation_status(self, text="", override_idle=False, + override_animations=False): + # NOT YET PRed to mycroft-core, taken from gez-mycroft wifi GUI test skill + self.clear() + self["icon"] = resolve_ovos_resource_file("ui/icons/check-circle.svg") + self["label"] = text + self["bgColor"] = "#40DBB0" + self.show_page("SYSTEM_status.qml", override_idle=override_idle, + override_animations=override_animations) + + def show_error_status(self, text="", override_idle=False, + override_animations=False): + # NOT YET PRed to mycroft-core, taken from gez-mycroft wifi GUI test skill + self.clear() + self["icon"] = resolve_ovos_resource_file("ui/icons/times-circle.svg") + self["label"] = text + self["bgColor"] = "#FF0000" + self.show_page("SYSTEM_status.qml", override_idle=override_idle, + override_animations=override_animations) + + # Media playback interactions + def play_video(self, url, title="", repeat=None, override_idle=True, + override_animations=True): + """ 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.""" + if self.is_video_displayed: + self["playStatus"] = "stop" + self.release() + self.video_info = None + + def resume_video(self): + """Resume paused video playback.""" + if self.__session_data.get("playStatus", "stop") == "pause": + self["playStatus"] = "play" + + if __name__ == "__main__": from ovos_utils import wait_for_exit_signal + LOG.set_level("DEBUG") g = GUITracker() - wait_for_exit_signal() \ No newline at end of file + wait_for_exit_signal() diff --git a/ovos_utils/playback/__init__.py b/ovos_utils/playback/__init__.py new file mode 100644 index 0000000..86e1627 --- /dev/null +++ b/ovos_utils/playback/__init__.py @@ -0,0 +1,5 @@ +from ovos_utils.playback.cps import CPSPlayback, CPSMatchConfidence, \ + CPSTrackStatus, CPSMatchType, CommonPlayInterface, BetterCommonPlayInterface +from ovos_utils.playback.youtube import get_youtube_metadata, \ + get_youtube_video_stream, get_youtube_audio_stream, is_youtube +from ovos_utils.playback.utils import get_duration_from_url diff --git a/ovos_utils/playback/cps.py b/ovos_utils/playback/cps.py new file mode 100644 index 0000000..7b292ba --- /dev/null +++ b/ovos_utils/playback/cps.py @@ -0,0 +1,763 @@ +from ovos_utils.messagebus import Message, get_mycroft_bus, wait_for_reply +from ovos_utils.skills.audioservice import AudioServiceInterface +from ovos_utils.gui import GUIInterface +from ovos_utils.log import LOG +from ovos_utils.playback.youtube import is_youtube, \ + get_youtube_audio_stream, get_youtube_video_stream +from enum import IntEnum +import time +import random + + +class CPSPlayback(IntEnum): + SKILL = 0 + GUI = 1 + AUDIO = 2 + + +class CPSMatchConfidence(IntEnum): + EXACT = 95 + VERY_HIGH = 90 + HIGH = 80 + AVERAGE_HIGH = 70 + AVERAGE = 50 + AVERAGE_LOW = 30 + LOW = 15 + VERY_LOW = 1 + + +class CPSTrackStatus(IntEnum): + DISAMBIGUATION = 1 # not queued for playback, show in gui + PLAYING = 20 # Skill is handling playback internally + PLAYING_AUDIOSERVICE = 21 # Skill forwarded playback to audio service + PLAYING_GUI = 22 # Skill forwarded playback to gui + PLAYING_ENCLOSURE = 23 # Skill forwarded playback to enclosure + QUEUED = 30 # Waiting playback to be handled inside skill + QUEUED_AUDIOSERVICE = 31 # Waiting playback in audio service + QUEUED_GUI = 32 # Waiting playback in gui + QUEUED_ENCLOSURE = 33 # Waiting for playback in enclosure + PAUSED = 40 # media paused but ready to resume + STALLED = 60 # playback has stalled, reason may be unknown + BUFFERING = 61 # media is buffering from an external source + END_OF_MEDIA = 90 # playback finished, is the default state when CPS loads + + +class CPSMatchType(IntEnum): + GENERIC = 0 + AUDIO = 1 + MUSIC = 2 + VIDEO = 3 + AUDIOBOOK = 4 + GAME = 5 + PODCAST = 6 + RADIO = 7 + NEWS = 8 + TV = 9 + MOVIE = 10 + TRAILER = 11 + ADULT = 12 + VISUAL_STORY = 13 + BEHIND_THE_SCENES = 14 + DOCUMENTARY = 15 + RADIO_THEATRE = 16 + SHORT_FILM = 17 + SILENT_MOVIE = 18 + BLACK_WHITE_MOVIE = 20 + + +class CommonPlayInterface: + """ interface for mycroft common play """ + + def __init__(self, bus=None): + self.bus = bus or get_mycroft_bus() + self.bus.on("play:query.response", self.handle_cps_response) + self.query_replies = {} + self.query_extensions = {} + self.waiting = False + self.start_ts = 0 + + @property + def cps_status(self): + return wait_for_reply('play:status.query', + reply_type="play:status.response", + bus=self.bus).data + + def handle_cps_response(self, message): + search_phrase = message.data["phrase"] + + if ("searching" in message.data and + search_phrase in self.query_extensions): + # Manage requests for time to complete searches + skill_id = message.data["skill_id"] + if message.data["searching"]: + # extend the timeout by N seconds + # IGNORED HERE, used in mycroft-playback-control skill + if skill_id not in self.query_extensions[search_phrase]: + self.query_extensions[search_phrase].append(skill_id) + else: + # Search complete, don't wait on this skill any longer + if skill_id in self.query_extensions[search_phrase]: + self.query_extensions[search_phrase].remove(skill_id) + + elif search_phrase in self.query_replies: + # Collect all replies until the timeout + self.query_replies[message.data["phrase"]].append(message.data) + + def send_query(self, phrase, media_type=CPSMatchType.GENERIC): + self.query_replies[phrase] = [] + self.query_extensions[phrase] = [] + self.bus.emit(Message('play:query', {"phrase": phrase, + "media_type": media_type})) + + def get_results(self, phrase): + if self.query_replies.get(phrase): + return self.query_replies[phrase] + return [] + + def search(self, phrase, media_type=CPSMatchType.GENERIC, timeout=5): + self.send_query(phrase, media_type) + self.waiting = True + start_ts = time.time() + while self.waiting and time.time() - start_ts <= timeout: + time.sleep(0.2) + self.waiting = False + res = self.get_results(phrase) + if res: + return res + if media_type != CPSMatchType.GENERIC: + return self.search(phrase, media_type=CPSMatchType.GENERIC, + timeout=timeout) + return [] + + def search_best(self, phrase, media_type=CPSMatchType.GENERIC, timeout=5): + # check responses + # Look at any replies that arrived before the timeout + # Find response(s) with the highest confidence + best = None + ties = [] + for handler in self.search(phrase, media_type, timeout): + if not best or handler["conf"] > best["conf"]: + best = handler + ties = [] + elif handler["conf"] == best["conf"]: + ties.append(handler) + + if best: + if ties: + # select randomly + skills = ties + [best] + selected = random.choice(skills) + # TODO: Ask user to pick between ties or do it + # automagically + else: + selected = best + + # will_resume = self.playback_status == CPSTrackStatus.PAUSED \ + # and not bool(phrase.strip()) + will_resume = False + return {"skill_id": selected["skill_id"], + "phrase": phrase, + "media_type": media_type, + "trigger_stop": not will_resume, + "callback_data": selected.get("callback_data")} + + return {} + + +class BetterCommonPlayInterface: + """ interface for better common play """ + + def __init__(self, bus=None, min_timeout=1, max_timeout=5, + allow_extensions=True, audio_service=None, gui=None, + backwards_compatibility=True, media_fallback=True): + """ + Arguments: + bus (MessageBus): mycroft messagebus connection + min_timeout (float): minimum time to wait for skill replies, + after this time, if at least 1 result was + found, selection is triggered + max_timeout (float): maximum time to wait for skill replies, + after this time, regardless of number of + results, selection is triggered + allow_extensions (bool): if True, allow skills to request more + time, extend min_timeout for specific + queries up to max_timeout + backwards_compatibility (bool): if True emits the regular + mycroft-core bus messages to get + results from "old style" skills + media_fallback (bool): if no results, perform a second query + with CPSMatchType.GENERIC + """ + self.bus = bus or get_mycroft_bus() + self.audio_service = audio_service or AudioServiceInterface(self.bus) + self.gui = gui or GUIInterface("better-cps", bus=self.bus) + + self.min_timeout = min_timeout + self.max_timeout = max_timeout + self.allow_extensions = allow_extensions + self.media_fallback = media_fallback + if backwards_compatibility: + self.old_cps = CommonPlayInterface(self.bus) + else: + self.old_cps = None + + self.query_replies = {} + self.query_timeouts = {} + self.waiting = False + self.search_start = 0 + self._search_results = [] + + self.playback_status = CPSTrackStatus.END_OF_MEDIA + self.active_backend = None # re-uses CPSTrackStatus.PLAYING_XXX + self.active_skill = None # skill_id currently handling playback + + self.playback_data = {"playing": None, + "playlist": [], + "disambiguation": []} + + self.bus.on("better_cps.query.response", self.handle_cps_response) + self.bus.on("better_cps.status.update", self.handle_cps_status_change) + self.register_gui_handlers() + + def shutdown(self): + self.bus.remove("better_cps.query.response", self.handle_cps_response) + self.bus.remove("better_cps.status.update", + self.handle_cps_status_change) + self.gui.shutdown() + + def handle_cps_response(self, message): + search_phrase = message.data["phrase"] + timeout = message.data.get("timeout") + LOG.debug(f"BetterCPS received results: {message.data['skill_id']}") + + if message.data.get("searching"): + # extend the timeout by N seconds + if timeout and self.allow_extensions and \ + search_phrase in self.query_timeouts: + self.query_timeouts[search_phrase] += timeout + # else -> expired search + + elif search_phrase in self.query_replies: + # Collect replies until the timeout + self.query_replies[search_phrase].append(message.data) + + # abort waiting if we gathered enough results + if time.time() - self.search_start > self.query_timeouts[ + search_phrase]: + self.waiting = False + + def search(self, phrase, media_type=CPSMatchType.GENERIC): + self.query_replies[phrase] = [] + self.query_timeouts[phrase] = self.min_timeout + self.search_start = time.time() + self.waiting = True + self.bus.emit(Message('better_cps.query', + {"phrase": phrase, + "media_type": media_type})) + + # old common play will send the messages expected by the official + # mycroft stack, but skills are know to over match, dont support + # match type, and the GUI is different for every skill, it may also + # cause issues with status tracking and mess up playlists + if self.old_cps: + self.old_cps.send_query(phrase, media_type) + + # if there is no match type defined, lets increase timeout a bit + # since all skills need to search + if media_type == CPSMatchType.GENERIC: + bonus = 3 # timeout bonus + else: + bonus = 0 + + while self.waiting and \ + time.time() - self.search_start <= self.max_timeout + bonus: + time.sleep(0.1) + + self.waiting = False + + # convert the returned data to the expected new format, playback + # type is consider Skill, better cps will not handle the playback + # life cycle but instead delegate to the skill + if self.old_cps: + old_style = self.old_cps.get_results(phrase) + self.query_replies[phrase] += self._convert_to_new_style(old_style, + media_type) + + if self.query_replies.get(phrase): + return [s for s in self.query_replies[phrase] if s.get("results")] + + # fallback to generic media type + if self.media_fallback and media_type != CPSMatchType.GENERIC: + LOG.debug("BetterCPS falling back to CPSMatchType.GENERIC") + return self.search(phrase, media_type=CPSMatchType.GENERIC) + return [] + + def search_skill(self, skill_id, phrase, media_type=CPSMatchType.GENERIC): + res = [r for r in self.search(phrase, media_type) + if r["skill_id"] == skill_id] + if not len(res): + return None + return res[0] + + def process_search(self, selected, results): + # TODO playlist + self._update_current_media(selected) + self._update_disambiguation(results) + self._set_search_results(results, best=selected) + self._set_now_playing(selected) + self.play() + + @staticmethod + def _convert_to_new_style(results, media_type=CPSMatchType.GENERIC): + new_style = [] + for res in results: + data = res['callback_data'] + data["skill_id"] = res["skill_id"] + data["phrase"] = res["phrase"] + data["is_old_style"] = True # internal flag for playback handling + data['match_confidence'] = res["conf"] * 100 + data["uri"] = data.get("stream") or \ + data.get("url") or \ + data.get("uri") + + # Essentially a random guess.... + data["media_type"] = media_type + data["playback"] = CPSPlayback.SKILL + if not data.get("image"): + data["image"] = data.get("logo") or \ + data.get("picture") + if not data.get("bg_image"): + data["bg_image"] = data.get("background") or \ + data.get("bg_picture") or \ + data.get("logo") or \ + data.get("picture") + + new_style.append({'phrase': res["phrase"], + "is_old_style": True, + 'results': [data], + 'searching': False, + 'skill_id': res["skill_id"]}) + return new_style + + # status tracking + def _update_current_media(self, data): + """ Currently playing media """ + self.playback_data["playing"] = data + + def _update_playlist(self, data): + """ List of queued media """ + self.playback_data["playlist"].append(data) + # sort playlist by requested order + self.playback_data["playlist"] = sorted( + self.playback_data["playlist"], + key=lambda i: int(i['playlist_position']) or 0) + + def _update_disambiguation(self, data): + """ List of unused search results """ + self.playback_data["disambiguation"].append(data) + + def handle_cps_status_change(self, message): + # message.data contains the media entry from search results and in + # addition a "status" for that entry, this can be used to control + # the playlist or simply communicate changes from the "playback + # backend" + status = message.data["status"] + + if status == CPSTrackStatus.PLAYING: + # skill is handling playback internally + self._update_current_media(message.data) + self.playback_status = status + self.active_backend = status + elif status == CPSTrackStatus.PLAYING_AUDIOSERVICE: + # audio service is handling playback + self._update_current_media(message.data) + self.playback_status = status + self.active_backend = status + elif status == CPSTrackStatus.PLAYING_GUI: + # gui is handling playback + self._update_current_media(message.data) + self.playback_status = status + self.active_backend = status + + elif status == CPSTrackStatus.DISAMBIGUATION: + # alternative results + self._update_disambiguation(message.data) + elif status == CPSTrackStatus.QUEUED: + # skill is handling playback and this is in playlist + self._update_playlist(message.data) + elif status == CPSTrackStatus.QUEUED_GUI: + # gui is handling playback and this is in playlist + self._update_playlist(message.data) + elif status == CPSTrackStatus.QUEUED_AUDIOSERVICE: + # audio service is handling playback and this is in playlist + self._update_playlist(message.data) + + elif status == CPSTrackStatus.PAUSED: + # media is not being played, but can be resumed anytime + # a new PLAYING status should be sent once playback resumes + self.playback_status = status + elif status == CPSTrackStatus.BUFFERING: + # media is buffering, might want to show in ui + # a new PLAYING status should be sent once playback resumes + self.playback_status = status + elif status == CPSTrackStatus.STALLED: + # media is stalled, might want to show in ui + # a new PLAYING status should be sent once playback resumes + self.playback_status = status + elif status == CPSTrackStatus.END_OF_MEDIA: + # if we add a repeat/loop flag this is the place to check for it + self.playback_status = status + + def update_status(self, status): + self.bus.emit(Message('better_cps.status.update', status)) + + # playback control + def play(self): + + data = self.playback_data.get("playing") or {} + uri = data.get("stream") or data.get("uri") or data.get("url") + skill_id = self.active_skill = data["skill_id"] + + self.stop() + + if data["playback"] == CPSPlayback.AUDIO: + data["status"] = CPSTrackStatus.PLAYING_AUDIOSERVICE + real_url = self.get_stream(uri) + self.audio_service.play(real_url) + + elif data["playback"] == CPSPlayback.SKILL: + data["status"] = CPSTrackStatus.PLAYING + if data.get("is_old_style"): + self.bus.emit(Message('play:start', + {"skill_id": skill_id, + "callback_data": data, + "phrase": data["phrase"]})) + else: + self.bus.emit(Message(f'better_cps.{skill_id}.play', data)) + elif data["playback"] == CPSPlayback.GUI: + pass # plays in display_ui + else: + raise ValueError("invalid playback request") + self.update_status(data) + self._set_now_playing(data) + self.display_ui() + self.update_player_status("Playing") + + @staticmethod + def get_stream(uri, video=False): + real_url = None + if is_youtube(uri): + if not video: + real_url = get_youtube_audio_stream(uri) + if video or not real_url: + real_url = get_youtube_video_stream(uri) + return real_url or uri + + def play_next(self): + # TODO playlist handling + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + pass + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.next() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.next')) + + def play_prev(self): + # TODO playlist handling + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + pass + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.prev() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.prev')) + + def pause(self): + self.update_status({"status": CPSTrackStatus.PAUSED}) + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + self.gui.pause_video() + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.pause() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.pause')) + + def resume(self): + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + self.gui.resume_video() + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.resume() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.resume')) + self.update_status({"status": self.active_backend}) + + def stop(self): + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + self.gui.stop_video() + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.stop() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.stop')) + self.update_status({"status": CPSTrackStatus.END_OF_MEDIA}) + stopped = self.active_backend is not None + self.active_backend = None + self.active_skill = None + return stopped + + # ######### GUI integration ############### + def register_gui_handlers(self): + self.gui.register_handler('better-cps.gui.play', + self.handle_click_resume) + self.gui.register_handler('better-cps.gui.pause', + self.handle_click_pause) + self.gui.register_handler('better-cps.gui.next', + self.handle_click_next) + self.gui.register_handler('better-cps.gui.previous', + self.handle_click_previous) + self.gui.register_handler('better-cps.gui.seek', + self.handle_click_seek) + + self.gui.register_handler('better-cps.gui.playlist.play', + self.handle_play_from_playlist) + self.gui.register_handler('better-cps.gui.search.play', + self.handle_play_from_search) + + def update_player_status(self, status, page=0): + self.gui["media"]["status"] = status + self.display_ui(page=page) + + def display_ui(self, search=None, media=None, playlist=None, page=0): + search_qml = "Disambiguation.qml" + player_qml = "AudioPlayer.qml" + video_player_qml = "VideoPlayer.qml" + playlist_qml = "Playlist.qml" + + media = media or self.gui.get("media") or {} + media["status"] = media.get("status", "Paused") + media["position"] = media.get("position", 0) + media["length"] = media.get("length") or -1 + search = search or self.gui.get("searchModel", {}).get("data") or {} + playlist = playlist or self.gui.get("playlistModel", {}).get("data") or {} + + # remove previous pages + pages = [player_qml, search_qml, playlist_qml, video_player_qml] + self.gui.remove_pages(pages) + + # display "now playing" video page + if media.get("playback", -1) == CPSPlayback.GUI: + uri = media.get("stream") or \ + media.get("url") or \ + media.get("uri") + self.gui["stream"] = self.get_stream(uri, video=True) + self.gui["title"] = media.get("title", "") + self.gui["playStatus"] = "play" + pages = [video_player_qml, search_qml, playlist_qml] + + # display "now playing" music page + else: + pages = [player_qml, search_qml, playlist_qml] + + self.gui["searchModel"] = {"data": search} + self.gui["playlistModel"] = {"data": playlist} + self.gui.show_pages(pages, page, override_idle=True) + + def _set_search_results(self, results, best=None): + best = best or results[0] + for idx, data in enumerate(results): + results[idx]["length"] = data.get("length") or \ + data.get("track_length") or \ + data.get("duration") + self._search_results = results + # send all results for disambiguation + # this can be used in GUI or any other use facing interface to + # override the final selection + for r in self._search_results: + status = dict(r) + status["status"] = CPSTrackStatus.DISAMBIGUATION + self.bus.emit(Message('better_cps.status.update', status)) + results = sorted(results, key=lambda k: k.get("match_confidence"), + reverse=True)[:100] + results = self._res2playlist(results) + playlist = self._res2playlist([best]) # TODO cps playlist + self.display_ui(media=best, playlist=playlist, search=results) + + @staticmethod + def _res2playlist(res): + playlist_data = [] + for r in res: + playlist_data.append({ + "album": r.get('skill_id'), + "duration": r.get('length'), + "image": r.get('image'), + "source": r.get('skill_icon') or r.get('skill_logo'), + "track": r.get("title") + }) + return playlist_data + + def _set_now_playing(self, data): + if data.get("bg_image", "").startswith("/"): + data["bg_image"] = "file:/" + data["bg_image"] + data["skill"] = data.get("skill_id", "better-cps") + data["position"] = data.get("position", 0) + + data["length"] = data.get("length") or data.get("track_length") or \ + data.get("duration") # or get_duration_from_url(url) + + self.gui["media"] = data + self.gui["bg_image"] = data.get("bg_image", + "https://source.unsplash.com/weekly?music") + + # gui events + def handle_click_pause(self, message): + self.audio_service.pause() + self.update_player_status("Paused") + + def handle_click_resume(self, message): + self.audio_service.resume() + self.update_player_status("Playing") + + def handle_click_next(self, message): + pass + + def handle_click_previous(self, message): + pass + + def handle_click_seek(self, message): + position = message.data.get("seekValue", "") + print("seek:", position) + if position: + self.audio_service.set_track_position(position / 1000) + self.gui["media"]["position"] = position + self.display_ui() + + def handle_play_from_playlist(self, message): + playlist_data = message.data["playlistData"] + self.__play(playlist_data) + + def handle_play_from_search(self, message): + res = self._res2playlist(self._search_results) + playlist_data = message.data["playlistData"] + idx = res.index(playlist_data) + self.__play(self._search_results[idx]) + + def __play(self, media): + playlist = self._res2playlist([media]) # TODO cps playlist + self.gui["playlistModel"] = {"data": playlist} + self._update_current_media(media) + self.play() + + +class CPSTracker: + def __init__(self, bus=None, gui=None): + self.bus = bus or get_mycroft_bus() + self.bus.on("better_cps.query.response", self.handle_cps_response) + self.bus.on("better_cps.status.update", self.handle_cps_status_change) + + self.gui = gui or GUIInterface("better-cps", bus=self.bus) + self.register_gui_handlers() + + def register_gui_handlers(self): + self.gui.register_handler('better-cps.gui.play', + self.handle_click_resume) + self.gui.register_handler('better-cps.gui.pause', + self.handle_click_pause) + self.gui.register_handler('better-cps.gui.next', + self.handle_click_next) + self.gui.register_handler('better-cps.gui.previous', + self.handle_click_previous) + self.gui.register_handler('better-cps.gui.seek', + self.handle_click_seek) + + self.gui.register_handler('better-cps.gui.playlist.play', + self.handle_play_from_playlist) + self.gui.register_handler('better-cps.gui.search.play', + self.handle_play_from_search) + + def shutdown(self): + self.bus.remove("better_cps.query.response", self.handle_cps_response) + self.bus.remove("better_cps.status.update", + self.handle_cps_status_change) + self.gui.shutdown() + + def handle_cps_response(self, message): + search_phrase = message.data["phrase"] + skill = message.data['skill_id'] + timeout = message.data.get("timeout") + + if message.data.get("searching"): + if timeout: + self.on_extend_timeout(search_phrase, skill, timeout) + else: + self.on_skill_results(search_phrase, skill, message.data) + + def handle_cps_status_change(self, message): + status = message.data["status"] + print("New status:", status) + + def handle_click_resume(self, message): + print(message.data) + + def handle_click_pause(self, message): + print(message.data) + + def handle_click_next(self, message): + print(message.data) + + def handle_click_previous(self, message): + print(message.data) + + def handle_click_seek(self, message): + print(message.data) + + def handle_play_from_playlist(self, message): + print(message.data) + + def handle_play_from_search(self, message): + print(message.data) + + # users can subclass these + def on_query(self, message): + pass + + def on_skill_results(self, phrase, skill_id, results): + pass + + def on_query_response(self, message): + pass + + def on_status_change(self, message): + pass + + def on_extend_timeout(self, phrase, timeout, skill_id): + print("extending timeout:", timeout, "\n", + "phrase:", phrase, "\n", + "skill:", skill_id, "\n") + + def on_skill_play(self, message): + pass + + def on_audio_play(self, message): + pass + + def on_gui_play(self, message): + pass + + +if __name__ == "__main__": + from pprint import pprint + + cps = BetterCommonPlayInterface(max_timeout=4, min_timeout=1) + res = cps.search_skill("skill-news", "portuguese", + media_type=CPSMatchType.NEWS) + if res: + res = sorted(res["results"], key=lambda k: k['match_confidence'], + reverse=True) + pprint(res) + # test lovecraft skills + #pprint(cps.search_skill("skill-omeleto", "movie", + # CPSMatchType.SHORT_FILM)) + + exit() + pprint(cps.search("the thing in the doorstep")) + + pprint(cps.search("dagon", CPSMatchType.VIDEO)) + + pprint(cps.search("dagon hp lovecraft")) diff --git a/ovos_utils/playback/utils.py b/ovos_utils/playback/utils.py new file mode 100644 index 0000000..b2ccf40 --- /dev/null +++ b/ovos_utils/playback/utils.py @@ -0,0 +1,25 @@ +from ovos_utils.playback.youtube import get_youtube_metadata, is_youtube +import requests + + +def get_duration_from_url(url): + """ return stream duration in milliseconds """ + if not url: + return 0 + if is_youtube(url): + data = get_youtube_metadata(url) + dur = data.get("length", 0) + else: + headers = requests.head(url).headers + #print(headers) + #dur = int(headers.get("Content-Length", 0)) + dur = 0 + return dur + + +def get_title_from_url(url): + """ return stream duration in milliseconds """ + if url and is_youtube(url): + data = get_youtube_metadata(url) + return data.get("title") + return url diff --git a/ovos_utils/playback/youtube.py b/ovos_utils/playback/youtube.py new file mode 100644 index 0000000..028818d --- /dev/null +++ b/ovos_utils/playback/youtube.py @@ -0,0 +1,99 @@ +import subprocess +from os.path import exists, join +from tempfile import gettempdir +from ovos_utils.log import LOG + +try: + import pafy +except ImportError: + pafy = None + + +def get_youtube_audio_stream(url, download=False, convert=False): + if pafy is None: + LOG.error("can not extract audio stream, pafy is not available") + LOG.info("pip install youtube-dl") + LOG.info("pip install pafy") + return url + try: + stream = pafy.new(url) + except: + return None + stream = stream.getbestaudio() + if not stream: + return None + + if download: + path = join(gettempdir(), + url.split("watch?v=")[-1] + "." + stream.extension) + + if not exists(path): + stream.download(path) + + if convert: + mp3 = join(gettempdir(), url.split("watch?v=")[-1] + ".mp3") + if not exists(mp3): + # convert file to mp3 + command = ["ffmpeg", "-n", "-i", path, "-acodec", + "libmp3lame", "-ab", "128k", mp3] + subprocess.call(command) + return mp3 + + return path + + return stream.url + + +def get_youtube_video_stream(url, download=False): + if pafy is None: + LOG.error("can not extract audio stream, pafy is not available") + LOG.info("pip install youtube-dl") + LOG.info("pip install pafy") + return url + try: + stream = pafy.new(url) + except: + return None + stream = stream.getbest() + if not stream: + return None + + if download: + path = join(gettempdir(), + url.split("watch?v=")[-1] + "." + stream.extension) + if not exists(path): + stream.download(path) + return path + return stream.url + + +def is_youtube(url): + # TODO localization + if not url: + return False + return "youtube.com/" in url or "youtu.be/" in url + + +def get_youtube_metadata(url): + if pafy is None: + LOG.error("can not extract audio stream, pafy is not available") + LOG.info("pip install youtube-dl") + LOG.info("pip install pafy") + return url + try: + stream = pafy.new(url) + except: + return {} + return { + "url": url, + #"audio_stream": stream.getbestaudio().url, + #"stream": stream.getbest().url, + "title": stream.title, + "author": stream.author, + "image": stream.getbestthumb().split("?")[0], +# "description": stream.description, + "length": stream.length * 1000, + "category": stream.category, +# "upload_date": stream.published, +# "tags": stream.keywords + } diff --git a/ovos_utils/res/ui/AudioPlayer.qml b/ovos_utils/res/ui/AudioPlayer.qml new file mode 100644 index 0000000..93a31e6 --- /dev/null +++ b/ovos_utils/res/ui/AudioPlayer.qml @@ -0,0 +1,329 @@ +/* + * Copyright 2020 by Aditya Mehra + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import QtQuick 2.12 +import QtMultimedia 5.12 +import QtQuick.Controls 2.12 as Controls +import QtQuick.Templates 2.12 as T +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.8 as Kirigami +import QtGraphicalEffects 1.0 +import Mycroft 1.0 as Mycroft + +Mycroft.Delegate { + id: root + skillBackgroundSource: media.bg_image + property alias thumbnail: albumimg.source + fillWidth: true + property int imageWidth: Kirigami.Units.gridUnit * 10 + skillBackgroundColorOverlay: Qt.rgba(0, 0, 0, 0.85) + property bool bigMode: width > 800 && height > 600 ? 1 : 0 + property bool horizontalMode: width >= height * 1.3 ? 1 : 0 + property bool isVertical: sessionData.isVertical + + // Assumption Track_Length is always in milliseconds + // Assumption current_Position is always in milleseconds and relative to track_length if track_length = 530000, position values range from 0 to 530000 + + property var media: sessionData.media + property var compareModel + property var playerDuration: media.length + property real playerPosition: 0 + property var playerState: media.status + property var nextAction: "gui.next" + property var previousAction: "gui.previous" + property bool countdowntimerpaused: false + + onIsVerticalChanged: { + if(isVertical){ + root.horizontalMode = false + } + } + + function formatedDuration(millis){ + var minutes = Math.floor(millis / 60000); + var seconds = ((millis % 60000) / 1000).toFixed(0); + return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; + } + + Controls.ButtonGroup { + id: autoPlayRepeatGroup + buttons: autoPlayRepeatGroupLayout.children + } + + onPlayerStateChanged: { + console.log(playerState) + if(playerState === "Playing"){ + playerPosition = media.position + countdowntimer.running = true + } else if(playerState === "Paused") { + playerPosition = media.position + countdowntimer.running = false + } + } + + Timer { + id: countdowntimer + interval: 1000 + running: false + repeat: true + onTriggered: { + if(media.length > playerPosition){ + if(!countdowntimerpaused){ + playerPosition = playerPosition + 1000 + } + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: bigMode ? parent.width * 0.025 : 0 + + Rectangle { + Layout.fillWidth: true + Layout.minimumHeight: songtitle.contentHeight + color: "transparent" + + Kirigami.Heading { + id: songtitle + text: media.title + level: 1 + maximumLineCount: 1 + width: parent.width + font.pixelSize: parent.width * 0.060 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + font.capitalization: Font.Capitalize + font.bold: true + visible: true + enabled: true + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + + GridLayout { + id: mainLayout + anchors.fill: parent + columns: horizontalMode ? 2 : 1 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: mainLayout.columns > 1 ? parent.height : parent.height / 1.5 + color: "transparent" + + Image { + id: albumimg + visible: true + enabled: true + width: parent.height * 0.9 + height: width + source: media.image + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + z: 100 + } + + + RectangularGlow { + id: effect + anchors.fill: albumimg + glowRadius: 5 + color: Qt.rgba(0, 0, 0, 0.7) + cornerRadius: 10 + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + + RowLayout { + anchors.fill: parent + anchors.margins: horizontalMode ? Kirigami.Units.largeSpacing : Kirigami.Units.largeSpacing * 2 + spacing: horizontalMode ? Kirigami.Units.largeSpacing * 3 : Kirigami.Units.largeSpacing * 5 + + Controls.Button { + id: previousButton + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + focus: false + KeyNavigation.right: playButton + KeyNavigation.down: seekableslider + onClicked: { + triggerGuiEvent(previousAction, {}) + } + + contentItem: Kirigami.Icon { + source: Qt.resolvedUrl("images/media-seek-backward.svg") + } + + background: Rectangle { + color: "transparent" + } + + Keys.onReturnPressed: { + clicked() + } + } + + Controls.Button { + id: playButton + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + onClicked: { + if (playerState != "Playing"){ + console.log("in resume action") + triggerGuiEvent("gui.play", {"media": { + "image": media.image, + "track": media.track, + "album": media.album, + "skill": media.skill, + "length": media.length, + "position": playerPosition, + "status": "Playing"}}) + } else { + triggerGuiEvent("gui.pause", {"media": { + "image": media.image, + "title": media.title, + "album": media.album, + "skill_id":media.skill, + "length": media.length, + "position": playerPosition, + "status": "Paused"}}) + } + } + + background: Rectangle { + color: "transparent" + } + + contentItem: Kirigami.Icon { + source: playerState === "Playing" ? Qt.resolvedUrl("images/media-playback-pause.svg") : Qt.resolvedUrl("images/media-playback-start.svg") + } + } + + Controls.Button { + id: nextButton + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + onClicked: { + triggerGuiEvent(nextAction, {}) + } + + background: Rectangle { + color: "transparent" + } + + contentItem: Kirigami.Icon { + source: Qt.resolvedUrl("images/media-seek-forward.svg") + } + } + } + } + } + } + } + + T.Slider { + id: seekableslider + to: playerDuration + Layout.fillWidth: true + Layout.minimumHeight: Kirigami.Units.gridUnit * 2 + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.rightMargin: Kirigami.Units.largeSpacing + Layout.bottomMargin: Kirigami.Units.largeSpacing + Layout.topMargin: Kirigami.Units.smallSpacing + property bool sync: false + live: false + visible: media.length !== -1 ? 1 : 0 + enabled: media.length !== -1 ? 1 : 0 + value: playerPosition + + onPressedChanged: { + if(seekableslider.pressed){ + root.countdowntimerpaused = true + } else ( + root.countdowntimerpaused = false + ) + } + + onValueChanged: { + if(root.countdowntimerpaused){ + triggerGuiEvent("gui.seek", {"seekValue": value}) + } + } + + handle: Item { + x: seekableslider.visualPosition * (parent.width - (Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing)) + anchors.verticalCenter: parent.verticalCenter + height: Kirigami.Units.iconSizes.large + + Rectangle { + id: hand + anchors.verticalCenter: parent.verticalCenter + implicitWidth: Kirigami.Units.iconSizes.small + Kirigami.Units.smallSpacing + implicitHeight: Kirigami.Units.iconSizes.small + Kirigami.Units.smallSpacing + radius: 100 + color: seekableslider.pressed ? "#f0f0f0" : "#f6f6f6" + border.color: "#bdbebf" + } + + Controls.Label { + anchors.bottom: parent.bottom + anchors.bottomMargin: -Kirigami.Units.smallSpacing + anchors.horizontalCenter: hand.horizontalCenter + //horizontalAlignment: Text.AlignHCenter + text: formatedDuration(playerPosition) + } + } + + background: Rectangle { + x: seekableslider.leftPadding + y: seekableslider.topPadding + seekableslider.availableHeight / 2 - height / 2 + implicitHeight: 10 + width: seekableslider.availableWidth + height: implicitHeight + Kirigami.Units.largeSpacing + radius: 10 + color: "#bdbebf" + + Rectangle { + width: seekableslider.visualPosition * parent.width + height: parent.height + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "#21bea6" } + GradientStop { position: 1.0; color: "#2194be" } + } + radius: 9 + } + } + } + } +} \ No newline at end of file diff --git a/ovos_utils/res/ui/Disambiguation.qml b/ovos_utils/res/ui/Disambiguation.qml new file mode 100644 index 0000000..2b9f85b --- /dev/null +++ b/ovos_utils/res/ui/Disambiguation.qml @@ -0,0 +1,171 @@ +/* + * Copyright 2020 by Aditya Mehra + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import QtQuick 2.9 +import QtQuick.Controls 2.3 as Controls +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.8 as Kirigami +import QtGraphicalEffects 1.0 +import Mycroft 1.0 as Mycroft + + +Mycroft.Delegate { + id: delegate + + property var playlistModel: sessionData.searchModel + property Component emptyHighlighter: Item{} + fillWidth: true + + skillBackgroundSource: sessionData.bg_image + + onPlaylistModelChanged: { + playlistListView.forceLayout() + } + + Keys.onBackPressed: { + parent.parent.parent.currentIndex-- + parent.parent.parent.currentItem.contentItem.forceActiveFocus() + } + + function formatedDuration(millis){ + var minutes = Math.floor(millis / 60000); + var seconds = ((millis % 60000) / 1000).toFixed(0); + return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; + } + + ColumnLayout { + id: playlistPlayerColumn + anchors.fill: parent + spacing: Kirigami.Units.smallSpacing + + Kirigami.Heading { + id: watchItemList + text: "Search Results" + level: 2 + } + + Kirigami.Separator { + id: sept2 + Layout.fillWidth: true + Layout.preferredHeight: 1 + z: 100 + } + + ListView { + id: playlistListView + keyNavigationEnabled: true + model: playlistModel.data + focus: false + interactive: true + bottomMargin: delegate.controlBarItem.height + Kirigami.Units.largeSpacing + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Kirigami.Units.largeSpacing + currentIndex: 0 + clip: true + highlightRangeMode: ListView.StrictlyEnforceRange + snapMode: ListView.SnapToItem + + delegate: Controls.ItemDelegate { + width: parent.width + height: Kirigami.Units.gridUnit * 5 + + background: Rectangle { + Kirigami.Theme.colorSet: Kirigami.Theme.Button + color: Qt.rgba(0.2, 0.2, 0.2, 1) + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 1 + verticalOffset: 2 + } + } + + + contentItem: Item { + width: parent.width + height: parent.height + + RowLayout { + id: delegateItem + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.largeSpacing + + Image { + id: videoImage + source: modelData.image + Layout.preferredHeight: parent.height + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + fillMode: Image.Stretch + } + + ColumnLayout { + Layout.fillWidth: true + + Controls.Label { + id: videoLabel + Layout.fillWidth: true + text: modelData.track + wrapMode: Text.WordWrap + color: "white" + } + Controls.Label { + id: artistLabel + Layout.fillWidth: true + text: modelData.album + opacity: 0.8 + color: "white" + } + } + + Controls.Label { + id: durationTime + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + color: "white" + opacity: 0.8 + text: formatedDuration(modelData.duration) + } + + Kirigami.Separator { + Layout.fillHeight: true + Layout.preferredWidth: 1 + } + + Image { + id: songSource + Layout.preferredHeight: Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing + Layout.preferredWidth: Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing + Layout.alignment: Qt.AlignHCenter + fillMode: Image.PreserveAspectFit + source: modelData.source + } + } + } + + onClicked: { + triggerGuiEvent("gui.search.play", + {"playlistData": modelData}) + } + } + } + } + + Component.onCompleted: { + playlistListView.forceActiveFocus() + } +} diff --git a/ovos_utils/res/ui/Playlist.qml b/ovos_utils/res/ui/Playlist.qml new file mode 100644 index 0000000..6d4f6e7 --- /dev/null +++ b/ovos_utils/res/ui/Playlist.qml @@ -0,0 +1,170 @@ +/* + * Copyright 2020 by Aditya Mehra + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import QtQuick 2.9 +import QtQuick.Controls 2.3 as Controls +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.8 as Kirigami +import QtGraphicalEffects 1.0 +import Mycroft 1.0 as Mycroft + + +Mycroft.Delegate { + id: delegate + + property var playlistModel: sessionData.playlistModel + property Component emptyHighlighter: Item{} + fillWidth: true + + skillBackgroundSource: sessionData.bg_image + + onPlaylistModelChanged: { + playlistListView.forceLayout() + } + + Keys.onBackPressed: { + parent.parent.parent.currentIndex-- + parent.parent.parent.currentItem.contentItem.forceActiveFocus() + } + + function formatedDuration(millis){ + var minutes = Math.floor(millis / 60000); + var seconds = ((millis % 60000) / 1000).toFixed(0); + return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; + } + + ColumnLayout { + id: playlistPlayerColumn + anchors.fill: parent + spacing: Kirigami.Units.smallSpacing + + Kirigami.Heading { + id: watchItemList + text: "Your Playlist" + level: 2 + } + + Kirigami.Separator { + id: sept2 + Layout.fillWidth: true + Layout.preferredHeight: 1 + z: 100 + } + + ListView { + id: playlistListView + keyNavigationEnabled: true + model: playlistModel.data + focus: false + interactive: true + bottomMargin: delegate.controlBarItem.height + Kirigami.Units.largeSpacing + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Kirigami.Units.largeSpacing + currentIndex: 0 + clip: true + highlightRangeMode: ListView.StrictlyEnforceRange + snapMode: ListView.SnapToItem + + delegate: Controls.ItemDelegate { + width: parent.width + height: Kirigami.Units.gridUnit * 5 + + background: Rectangle { + Kirigami.Theme.colorSet: Kirigami.Theme.Button + color: Qt.rgba(0.2, 0.2, 0.2, 1) + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 1 + verticalOffset: 2 + } + } + + + contentItem: Item { + width: parent.width + height: parent.height + + RowLayout { + id: delegateItem + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.largeSpacing + + Image { + id: videoImage + source: modelData.image + Layout.preferredHeight: parent.height + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + fillMode: Image.Stretch + } + + ColumnLayout { + Layout.fillWidth: true + + Controls.Label { + id: videoLabel + Layout.fillWidth: true + text: modelData.track + wrapMode: Text.WordWrap + color: "white" + } + Controls.Label { + id: artistLabel + Layout.fillWidth: true + text: modelData.album + opacity: 0.8 + color: "white" + } + } + + Controls.Label { + id: durationTime + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + color: "white" + opacity: 0.8 + text: formatedDuration(modelData.duration) + } + + Kirigami.Separator { + Layout.fillHeight: true + Layout.preferredWidth: 1 + } + + Image { + id: songSource + Layout.preferredHeight: Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing + Layout.preferredWidth: Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing + Layout.alignment: Qt.AlignHCenter + fillMode: Image.PreserveAspectFit + source: modelData.source + } + } + } + + onClicked: { + triggerGuiEvent("gui.playlist.play", {"playlistData": modelData}) + } + } + } + } + + Component.onCompleted: { + playlistListView.forceActiveFocus() + } +} diff --git a/ovos_utils/res/ui/SeekControl.qml b/ovos_utils/res/ui/SeekControl.qml index 7cda31b..0ce605e 100644 --- a/ovos_utils/res/ui/SeekControl.qml +++ b/ovos_utils/res/ui/SeekControl.qml @@ -62,44 +62,7 @@ Item { 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: { - Mycroft.MycroftController.sendRequest("mycroft.gui.screen.close", {}); - video.stop(); - } - KeyNavigation.up: video - KeyNavigation.right: button - Keys.onReturnPressed: { - hideTimer.restart(); - Mycroft.MycroftController.sendRequest("mycroft.gui.screen.close", {}); - video.stop(); - } - onFocusChanged: { - hideTimer.restart(); - } - } + Controls.RoundButton { id: button Layout.preferredWidth: parent.width > 600 ? Kirigami.Units.iconSizes.large : Kirigami.Units.iconSizes.medium diff --git a/ovos_utils/res/ui/VideoPlayer.qml b/ovos_utils/res/ui/VideoPlayer.qml new file mode 100644 index 0000000..0b7f18e --- /dev/null +++ b/ovos_utils/res/ui/VideoPlayer.qml @@ -0,0 +1,205 @@ +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 media: sessionData.media + property var videoSource: sessionData.stream + property var videoStatus: media.status + 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(); + } + + onVideoStatusChanged: { + switch(videoStatus){ + case "Stopped": + video.stop(); + break; + case "Paused": + video.pause() + break; + case "Playing": + 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 == "Playing") { + video.play(); + } else if (videoStatus == "Stopped") { + 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: media.title + 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: media.title + 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/ovos_utils/res/ui/images/media-playback-pause.svg b/ovos_utils/res/ui/images/media-playback-pause.svg index 972dfa2..d6a4dd1 100644 --- a/ovos_utils/res/ui/images/media-playback-pause.svg +++ b/ovos_utils/res/ui/images/media-playback-pause.svg @@ -1,61 +1,8 @@ - - - - - - image/svg+xml - - - - - - - - - + + + diff --git a/ovos_utils/res/ui/images/media-playback-start.svg b/ovos_utils/res/ui/images/media-playback-start.svg index 4627f98..25c5fab 100644 --- a/ovos_utils/res/ui/images/media-playback-start.svg +++ b/ovos_utils/res/ui/images/media-playback-start.svg @@ -1,61 +1,8 @@ - - - - - - image/svg+xml - - - - - - - - - + + + diff --git a/ovos_utils/res/ui/images/media-playback-stop.svg b/ovos_utils/res/ui/images/media-playback-stop.svg new file mode 100644 index 0000000..32a0101 --- /dev/null +++ b/ovos_utils/res/ui/images/media-playback-stop.svg @@ -0,0 +1,8 @@ + + + + diff --git a/ovos_utils/res/ui/images/media-playlist-play.svg b/ovos_utils/res/ui/images/media-playlist-play.svg new file mode 100644 index 0000000..66e47ba --- /dev/null +++ b/ovos_utils/res/ui/images/media-playlist-play.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/ovos_utils/res/ui/images/media-playlist-repeat.svg b/ovos_utils/res/ui/images/media-playlist-repeat.svg index 01c10dd..6ef525f 100644 --- a/ovos_utils/res/ui/images/media-playlist-repeat.svg +++ b/ovos_utils/res/ui/images/media-playlist-repeat.svg @@ -2,7 +2,7 @@ diff --git a/ovos_utils/res/ui/images/media-seek-backward.svg b/ovos_utils/res/ui/images/media-seek-backward.svg new file mode 100644 index 0000000..8a5d334 --- /dev/null +++ b/ovos_utils/res/ui/images/media-seek-backward.svg @@ -0,0 +1,8 @@ + + + + diff --git a/ovos_utils/res/ui/images/media-seek-forward.svg b/ovos_utils/res/ui/images/media-seek-forward.svg new file mode 100644 index 0000000..5243967 --- /dev/null +++ b/ovos_utils/res/ui/images/media-seek-forward.svg @@ -0,0 +1,8 @@ + + + + diff --git a/ovos_utils/skills/audioservice.py b/ovos_utils/skills/audioservice.py new file mode 100644 index 0000000..93bc650 --- /dev/null +++ b/ovos_utils/skills/audioservice.py @@ -0,0 +1,204 @@ +# Copyright 2017 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# This file is directly copied from mycroft-core, it's a simple utility to +# interface with the AudioService via messagebus outside core +from os.path import abspath +from ovos_utils.messagebus import Message, get_mycroft_bus + + +def ensure_uri(s): + """Interprete paths as file:// uri's. + + Arguments: + s: string to be checked + + Returns: + if s is uri, s is returned otherwise file:// is prepended + """ + if isinstance(s, str): + if '://' not in s: + return 'file://' + abspath(s) + else: + return s + elif isinstance(s, (tuple, list)): + if '://' not in s[0]: + return 'file://' + abspath(s[0]), s[1] + else: + return s + else: + raise ValueError('Invalid track') + + +class AudioServiceInterface: + """AudioService class for interacting with the audio subsystem + + Arguments: + bus: Mycroft messagebus connection + """ + + def __init__(self, bus=None): + self.bus = bus or get_mycroft_bus() + + def queue(self, tracks=None): + """Queue up a track to playing playlist. + + Arguments: + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system + """ + tracks = tracks or [] + if isinstance(tracks, (str, tuple)): + tracks = [tracks] + elif not isinstance(tracks, list): + raise ValueError + tracks = [ensure_uri(t) for t in tracks] + self.bus.emit(Message('mycroft.audio.service.queue', + data={'tracks': tracks})) + + def play(self, tracks=None, utterance=None, repeat=None): + """Start playback. + + Arguments: + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system + utterance: forward utterance for further processing by the + audio service. + repeat: if the playback should be looped + """ + repeat = repeat or False + tracks = tracks or [] + utterance = utterance or '' + if isinstance(tracks, (str, tuple)): + tracks = [tracks] + elif not isinstance(tracks, list): + raise ValueError + tracks = [ensure_uri(t) for t in tracks] + self.bus.emit(Message('mycroft.audio.service.play', + data={'tracks': tracks, + 'utterance': utterance, + 'repeat': repeat})) + + def stop(self): + """Stop the track.""" + self.bus.emit(Message('mycroft.audio.service.stop')) + + def next(self): + """Change to next track.""" + self.bus.emit(Message('mycroft.audio.service.next')) + + def prev(self): + """Change to previous track.""" + self.bus.emit(Message('mycroft.audio.service.prev')) + + def pause(self): + """Pause playback.""" + self.bus.emit(Message('mycroft.audio.service.pause')) + + def resume(self): + """Resume paused playback.""" + self.bus.emit(Message('mycroft.audio.service.resume')) + + def get_track_length(self): + """ + getting the duration of the audio in mlilliseconds + """ + info = self.bus.wait_for_response( + Message('mycroft.audio.service.get_track_length'), + timeout=1) + if info: + return info.data.get("length") + return 0 + + def get_track_position(self): + """ + get current position in milliseconds + + Args: + seconds (int): number of seconds of final position + """ + info = self.bus.wait_for_response( + Message('mycroft.audio.service.get_track_position'), + timeout=1) + if info: + return info.data.get("position") + return 0 + + def set_track_position(self, seconds=1): + """Seek X seconds. + + Arguments: + seconds (int): number of seconds to seek, if negative rewind + """ + self.bus.emit(Message('mycroft.audio.service.set_track_position', + {"seconds": seconds})) + + def seek(self, seconds=1): + """Seek X seconds. + + Arguments: + seconds (int): number of seconds to seek, if negative rewind + """ + if seconds < 0: + self.seek_backward(abs(seconds)) + else: + self.seek_forward(seconds) + + def seek_forward(self, seconds=1): + """Skip ahead X seconds. + + Arguments: + seconds (int): number of seconds to skip + """ + self.bus.emit(Message('mycroft.audio.service.seek_forward', + {"seconds": seconds})) + + def seek_backward(self, seconds=1): + """Rewind X seconds + + Arguments: + seconds (int): number of seconds to rewind + """ + self.bus.emit(Message('mycroft.audio.service.seek_backward', + {"seconds": seconds})) + + def track_info(self): + """Request information of current playing track. + + Returns: + Dict with track info. + """ + info = self.bus.wait_for_response( + Message('mycroft.audio.service.track_info'), + reply_type='mycroft.audio.service.track_info_reply', + timeout=1) + return info.data if info else {} + + def available_backends(self): + """Return available audio backends. + + Returns: + dict with backend names as keys + """ + msg = Message('mycroft.audio.service.list_backends') + response = self.bus.wait_for_response(msg) + return response.data if response else {} + + @property + def is_playing(self): + """True if the audioservice is playing, else False.""" + return self.track_info() != {} diff --git a/ovos_utils/skills/templates/common_play.py b/ovos_utils/skills/templates/common_play.py new file mode 100644 index 0000000..965f299 --- /dev/null +++ b/ovos_utils/skills/templates/common_play.py @@ -0,0 +1,127 @@ +from abc import abstractmethod +from ovos_utils.waiting_for_mycroft.base_skill import MycroftSkill +from ovos_utils.playback import CPSMatchType +from ovos_utils.messagebus import Message + + +class BetterCommonPlaySkill(MycroftSkill): + """ To integrate with the better common play infrastructure of Mycroft + skills should use this base class and override + `CPS_search` (for searching the skill for media to play ) and + `CPS_play` for launching the media if desired. + + The class makes the skill available to queries from the + better-playback-control skill and no special vocab for starting playback + is needed. + """ + + def __init__(self, name=None, bus=None): + super().__init__(name, bus) + self.supported_media = [CPSMatchType.GENERIC, CPSMatchType.AUDIO] + self._current_query = None + # NOTE: derived skills will likely want to override this list + + def bind(self, bus): + """Overrides the normal bind method. + + Adds handlers for play:query and play:start messages allowing + interaction with the playback control skill. + + This is called automatically during setup, and + need not otherwise be used. + """ + if bus: + super().bind(bus) + self.add_event('better_cps.query', self.__handle_cps_query) + self.add_event(f'better_cps.{self.skill_id}.play', + self.__handle_cps_play) + + def __handle_cps_play(self, message): + self.CPS_play(message.data) + + def __handle_cps_query(self, message): + """Query skill if it can start playback from given phrase.""" + search_phrase = message.data["phrase"] + self._current_query = search_phrase + media_type = message.data.get("media_type", CPSMatchType.GENERIC) + + if media_type not in self.supported_media: + return + + # invoke the CPS handler to let the skill perform its search + results = self.CPS_search(search_phrase, media_type) + + if results: + # inject skill id in individual results, will be needed later + # for proper GUI playback handling + for idx, r in enumerate(results): + results[idx]["skill_id"] = self.skill_id + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "results": results, + "searching": False})) + else: + # Signal we are done (can't handle it) + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "searching": False})) + + def CPS_extend_timeout(self, timeout=0.5): + """ request more time for searching, limits are defined by + better-common-play framework, by default max total time is 5 seconds + per query """ + if self._current_query: + self.bus.emit(Message("better_cps.query.response", + {"phrase": self._current_query, + "skill_id": self.skill_id, + "timeout": timeout, + "searching": True})) + + @abstractmethod + def CPS_search(self, phrase, media_type): + """Analyze phrase to see if it is a play-able phrase with this skill. + + Arguments: + phrase (str): User phrase uttered after "Play", e.g. "some music" + media_type (CPSMatchType): requested CPSMatchType to search for + + if a result from here is selected with CPSPlayback.SKILL then + CPS_play will be called with result data as argument + + Returns: + search_results (list): list of dictionaries with result entries + { + "match_confidence": CPSMatchConfidence.HIGH, + "media_type": CPSMatchType.MUSIC, + "uri": "https://audioservice.or.gui.will.play.this", + "playback": CPSPlayback.GUI, + "image": "http://optional.audioservice.jpg", + "bg_image": "http://optional.audioservice.background.jpg" + } + """ + return [] + + @abstractmethod + def CPS_play(self, data): + """Skill was selected for playback + + Playback will be handled manually by the skill, eg, spotify or some + other external service + + NOTE: CPSPlayback.AUDIO and CPSPlayback.GUI are handled + automatically by BetterCommonPlay, this is only called for + CPSPlayback.SKILL results + + Arguments: + data (dict): selected data previously returned in CPS_search + + { + "match_confidence": CPSMatchConfidence.HIGH, + "media_type": CPSMatchType.MUSIC, + "uri": "https://audioservice.or.gui.will.play.this", + "playback": CPSPlayback.SKILL, + "image": "http://optional.audioservice.jpg", + "bg_image": "http://optional.audioservice.background.jpg" + } + """ + pass diff --git a/ovos_utils/skills/templates/media_player.py b/ovos_utils/skills/templates/media_player.py deleted file mode 100644 index 6db0fc1..0000000 --- a/ovos_utils/skills/templates/media_player.py +++ /dev/null @@ -1,151 +0,0 @@ -from ovos_utils.waiting_for_mycroft.common_play import CommonPlaySkill, \ - CPSMatchLevel, CPSTrackStatus, CPSMatchType -from ovos_utils import create_daemon -from os.path import join, dirname, basename -from ovos_utils import get_mycroft_root, resolve_ovos_resource_file -from ovos_utils.log import LOG -import random - -try: - from mycroft.skills.core import intent_file_handler -except ImportError: - import sys - - MYCROFT_ROOT_PATH = get_mycroft_root() - if MYCROFT_ROOT_PATH is not None: - sys.path.append(MYCROFT_ROOT_PATH) - from mycroft.skills.core import intent_file_handler - else: - LOG.error("Could not find mycroft root path") - raise ImportError - -try: - import pyvod -except ImportError: - pyvod = None - - -class MediaSkill(CommonPlaySkill): - """ - common play skills can be made by just returning the - expected data in CPS_match_query_phrase - - return (phrase, match, - {"image": self.default_image, # optional - "background": self.default_bg, # optional - "stream": random.choice(self.bootstrap_list)}) - - depending on skill settings - - will handle bootstrapping media (download on startup) - - set self.bootstrap_list to a list of urls in __init__ - - will handle audio only VS video - - will handle conversion to mp3 (compatibility with simple audio backend) - - CPS_start should not be overrided - - supports direct urls - - supports youtube urls - - supports every website youtube-dl supports - - will handle setting initial track status - - will fallback to audio only if GUI not connected - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if "download_audio" not in self.settings: - self.settings["download_audio"] = False - if "download_video" not in self.settings: - self.settings["download_video"] = False - if "audio_only" not in self.settings: - self.settings["audio_only"] = False - if "audio_with_video_stream" not in self.settings: - self.settings["audio_with_video_stream"] = False - if "mp3_audio" not in self.settings: - self.settings["mp3_audio"] = True - if "preferred_audio_backend" not in self.settings: - self.settings["preferred_audio_backend"] = None - self.message_namespace = basename(dirname(__file__)) + ".ovos_utils" - self.default_bg = "https://github.com/OpenVoiceOS/ovos_assets/raw/master/Logo/ovos-logo-512.png" - self.default_image = resolve_ovos_resource_file( - "ui/images/moviesandfilms.png") - self.bootstrap_list = [] - if pyvod is None: - LOG.error("py_VOD not installed!") - LOG.info("pip install py_VOD>=0.4.0") - raise ImportError - - def initialize(self): - self.add_event( - '{msg_base}.home'.format(msg_base=self.message_namespace), - self.handle_homescreen) - create_daemon(self.handle_bootstrap) - - def handle_bootstrap(self): - # bootstrap, so data is cached - for url in self.bootstrap_list: - try: - if self.settings["download_audio"]: - pyvod.utils.get_audio_stream(url, download=True, - to_mp3=self.settings[ - "mp3_audio"]) - if self.settings["download_video"]: - pyvod.utils.get_video_stream(url, download=True) - except: - pass - - # homescreen - def handle_homescreen(self, message): - # users are supposed to override this - self.CPS_start(self.name, - {"image": self.default_image, - "background": self.default_bg, - "stream": random.choice(self.bootstrap_list)}) - - def CPS_match_query_phrase(self, phrase, media_type): - # users are supposed to override this - original = phrase - match = None - - if match is not None: - return (phrase, match, - {"media_type": media_type, "query": original, - "image": self.default_image, - "background": self.default_bg, - "stream": random.choice(self.bootstrap_list)}) - return None - - def CPS_start(self, phrase, data): - self.play_media(data) - - def play_media(self, data): - bg = data.get("background") or self.default_bg - image = data.get("image") or self.default_image - url = data["stream"] - if self.gui.connected and not self.settings["audio_only"]: - url = pyvod.utils.get_video_stream( - url, download=self.settings["download_video"]) - self.CPS_send_status(uri=url, - image=image, - background_image=bg, - playlist_position=0, - status=CPSTrackStatus.PLAYING_GUI) - self.gui.play_video(url, self.name) - else: - utt = self.settings["preferred_audio_backend"] or self.play_service_string - if self.settings["audio_with_video_stream"]: - # This might look stupid, but for youtube live streams it's - # needed, mycroft-core/pull/2791 should also be in for this - # to work properly - url = pyvod.utils.get_video_stream(url) - else: - url = pyvod.utils.get_audio_stream( - url, download=self.settings["download_audio"], - to_mp3=self.settings["mp3_audio"]) - self.audioservice.play(url, utterance=utt) - self.CPS_send_status(uri=url, - image=image, - background_image=bg, - playlist_position=0, - status=CPSTrackStatus.PLAYING_AUDIOSERVICE) - - def stop(self): - self.gui.release() diff --git a/ovos_utils/skills/templates/video_collection.py b/ovos_utils/skills/templates/video_collection.py index b1f9132..0de48bb 100644 --- a/ovos_utils/skills/templates/video_collection.py +++ b/ovos_utils/skills/templates/video_collection.py @@ -1,12 +1,13 @@ -from ovos_utils.waiting_for_mycroft.common_play import CommonPlaySkill, \ - CPSMatchLevel, CPSTrackStatus, CPSMatchType from os.path import join, dirname, basename import random from ovos_utils import get_mycroft_root, datestr2ts, resolve_ovos_resource_file from ovos_utils.log import LOG from ovos_utils.parse import fuzzy_match +from ovos_utils.json_helper import merge_dict from json_database import JsonStorageXDG - +import random +from ovos_utils.skills.templates.common_play import BetterCommonPlaySkill +from ovos_utils.playback import CPSMatchType, CPSPlayback, CPSMatchConfidence try: from mycroft.skills.core import intent_file_handler except ImportError: @@ -27,7 +28,7 @@ pyvod = None -class VideoCollectionSkill(CommonPlaySkill): +class VideoCollectionSkill(BetterCommonPlaySkill): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -43,11 +44,32 @@ def __init__(self, *args, **kwargs): self.settings["filter_live"] = False if "filter_date" not in self.settings: self.settings["filter_date"] = False + if "min_score" not in self.settings: + self.settings["min_score"] = 40 + if "match_description" not in self.settings: + self.settings["match_description"] = True + if "match_tags" not in self.settings: + self.settings["match_tags"] = True + if "match_title" not in self.settings: + self.settings["match_title"] = True + if "filter_trailers" not in self.settings: + self.settings["filter_trailers"] = True + if "filter_behind_scenes" not in self.settings: + self.settings["filter_behind_scenes"] = True + if "search_depth" not in self.settings: + # after matching and ordering by title + # will match/search metadata for N videos + # some collection can be huge and matching everything will cause + # a timeout, collections with less than N videos wont have any + # problem + self.settings["search_depth"] = 500 if pyvod is None: LOG.error("py_VOD not installed!") LOG.info("pip install py_VOD>=0.4.0") raise ImportError + self.playback_type = CPSPlayback.GUI + self.media_type = CPSMatchType.VIDEO self.default_bg = "https://github.com/OpenVoiceOS/ovos_assets/raw/master/Logo/ovos-logo-512.png" self.default_image = resolve_ovos_resource_file("ui/images/moviesandfilms.png") db_path = join(dirname(__file__), "res", self.name + ".jsondb") @@ -56,7 +78,6 @@ def __init__(self, *args, **kwargs): logo=self.default_image, db_path=db_path) - def initialize(self): self.initialize_media_commons() @@ -91,6 +112,9 @@ def videos(self): else: videos[idx]["url"] = videos[idx].get("stream") or \ videos[idx].get("url") + # convert duration to milliseconds + if v.get("duration"): + videos[idx]["length"] = v["duration"] * 1000 # return sorted return self.sort_videos(videos) except Exception as e: @@ -137,6 +161,20 @@ def filter_videos(self, videos): # TODO filter behind the scenes, clips etc based on # title/tags/description/keywords required or forbidden + # filter trailers + if self.settings["filter_trailers"] and \ + CPSMatchType.TRAILER not in self.supported_media: + # TODO bundle .voc for "trailer" + videos = [v for v in videos + if not self.voc_match(v["title"], "trailer")] + + # filter behind the scenes + if self.settings["filter_behind_scenes"] and \ + CPSMatchType.BEHIND_THE_SCENES not in self.supported_media: + # TODO bundle .voc for "behind_scenes" + videos = [v for v in videos + if not self.voc_match(v["title"], "behind_scenes")] + if self.settings["shuffle_menu"]: random.shuffle(videos) @@ -159,7 +197,7 @@ def handle_homescreen(self, message): def play_video_event(self, message): video_data = message.data["modelData"] if video_data["skill_id"] == self.skill_id: - self.play_video(video_data) + pass # TODO # watch history database def add_to_history(self, video_data): @@ -181,64 +219,79 @@ def handle_clear_history(self, message): # matching def match_media_type(self, phrase, media_type): - match = None - score = 0 - + base_score = 0 if media_type == CPSMatchType.VIDEO: - score += 0.05 - match = CPSMatchLevel.GENERIC - - return match, score + base_score += 5 + if media_type != self.media_type: + base_score -= 20 + return base_score def augment_tags(self, phrase, media_type, tags=None): return tags or [] - def match_tags(self, video, phrase, match, media_type): + def match_tags(self, video, phrase, media_type): score = 0 # score tags - leftover_text = phrase tags = list(set(video.get("tags") or [])) tags = self.augment_tags(phrase, media_type, tags) if tags: # tag match bonus for tag in tags: tag = tag.lower().strip() + if tag in phrase.split(" "): + score += 10 if tag in phrase: - match = CPSMatchLevel.CATEGORY - score += 0.05 - leftover_text = leftover_text.replace(tag, "") - return match, score, leftover_text + score += 3 + return score - def match_description(self, video, phrase, match): + def match_description(self, video, phrase, media_type): # score description score = 0 leftover_text = phrase words = video.get("description", "").split(" ") for word in words: if len(word) > 4 and word in self.normalize_title(leftover_text): - score += 0.05 + score += 1 leftover_text = leftover_text.replace(word, "") - return match, score, leftover_text + return score - def match_title(self, videos, phrase, match): + def match_title(self, video, phrase, media_type): # match video name clean_phrase = self.normalize_title(phrase) - leftover_text = phrase - best_score = 0 - best_video = random.choice(videos) - for video in videos: - title = video["title"] - score = fuzzy_match(clean_phrase, self.normalize_title(title)) - if phrase.lower() in title.lower() or \ - clean_phrase in self.normalize_title(title): - score += 0.3 - if score >= best_score: - # TODO handle ties - match = CPSMatchLevel.TITLE - best_video = video - best_score = score - leftover_text = phrase.replace(title, "") - return match, best_score, best_video, leftover_text + title = video["title"] + score = fuzzy_match(clean_phrase, self.normalize_title(title)) * 100 + if phrase.lower() in title.lower() or \ + clean_phrase in self.normalize_title(title): + score += 25 + if phrase.lower() in title.lower().split(" ") or \ + clean_phrase in self.normalize_title(title).split(" "): + score += 30 + + if media_type == CPSMatchType.TRAILER: + if self.voc_match(title, "trailer"): + score += 20 + else: + score -= 10 + elif self.settings["filter_trailers"] and \ + self.voc_match(title, "trailer") or \ + "trailer" in title.lower(): + # trailer in title, but not in media_type, let's skip it + # TODO bundle trailer.voc in ovos_utils + score = 0 + + if media_type == CPSMatchType.BEHIND_THE_SCENES: + if self.voc_match(title, "behind_scenes"): + score += 20 + else: + score -= 10 + elif self.settings["filter_behind_scenes"] and \ + self.voc_match(title, "behind_scenes") or \ + "behind the scenes" in title.lower(): + # trailer in title, but not in media_type, let's skip it + # TODO bundle behind_scenes.voc in ovos_utils + score = 0 + + return score def normalize_title(self, title): title = title.lower().strip() @@ -250,107 +303,40 @@ def normalize_title(self, title): # spaces # common play - def calc_final_score(self, phrase, base_score, match_level): - return base_score, match_level - - def base_CPS_match(self, phrase, media_type): - best_score = 0 - # see if media type is in query, base_score will depend if "video" is in query - match, base_score = self.match_media_type(phrase, media_type) - videos = list(self.videos) - best_video = random.choice(self.videos) - # match video data - scores = [] - for video in videos: - match, score, _ = self.match_tags(video, phrase, match, media_type) - # match, score, leftover_text = self.match_description(video, leftover_text, match) - scores.append((video, score)) - if score > best_score: - best_video = video - best_score = score - - self.log.debug("Best Tags Match: {s}, {t}".format( - s=best_score, t=best_video["title"])) - - # match video name - match, title_score, best_title, leftover_text = self.match_title( - videos, phrase, match) - self.log.debug("Best Title Match: {s}, {t}".format( - s=title_score, t=best_title["title"])) - - # title more important than tags - if title_score + 0.15 > best_score: - best_video = best_title - best_score = title_score - - # sort matches - scores = sorted(scores, key=lambda k: k[1], reverse=True) - scores.insert(0, (best_title, title_score)) - scores.remove((best_video, best_score)) - scores.insert(0, (best_video, best_score)) - - # choose from top N - if best_score < 0.5: - n = 50 - elif best_score < 0.6: - n = 10 - elif best_score < 0.8: - n = 3 - else: - n = 1 - - candidates = scores[:n] - self.log.info("Choosing randomly from top {n} matches".format( - n=len(candidates))) - best_video = random.choice(candidates)[0] - - # calc final confidence - score = base_score + best_score - score = self.calc_final_score(phrase, score, match) - if isinstance(score, float): - if score >= 0.9: - match = CPSMatchLevel.EXACT - elif score >= 0.7: - match = CPSMatchLevel.MULTI_KEY - elif score >= 0.5: - match = CPSMatchLevel.TITLE - else: - score, match = score - - self.log.info("Best video: " + best_video["title"]) - - if match is not None: - return (leftover_text, match, best_video) - return None - - def CPS_match_query_phrase(self, phrase, media_type): - match = self.base_CPS_match(phrase, media_type) - if match is None: - return None - # match == (leftover_text, CPSMatchLevel, best_video_data) - return match - - def CPS_start(self, phrase, data): - self.play_video(data) - - def play_video(self, data): - self.add_to_history(data) - bg = data.get("background") or self.default_bg - image = data.get("image") or self.default_image - - if len(data.get("streams", [])): - url = data["streams"][0] - else: - url = data.get("stream") or data.get("url") - - title = data.get("name") or self.name - self.CPS_send_status(uri=url, - image=image, - background_image=bg, - playlist_position=0, - status=CPSTrackStatus.PLAYING_GUI) - self.gui.play_video(pyvod.utils.get_video_stream(url), title) - - def stop(self): - self.gui.release() + def CPS_search(self, phrase, media_type): + base_score = self.match_media_type(phrase, media_type) + # penalty for generic searches, they tend to overmatch + if media_type == CPSMatchType.GENERIC: + base_score -= 20 + # match titles and sort + # then match all the metadata up to self.settings["search_depth"] + videos = sorted(self.videos, + key=lambda k: fuzzy_match(k["title"], phrase), + reverse=True) + cps_results = [] + for idx, video in enumerate(videos[:self.settings["search_depth"]]): + score = base_score + fuzzy_match(video["title"], phrase) * 30 + if self.settings["match_tags"]: + score += self.match_tags(video, phrase, media_type) + if self.settings["match_title"]: + score += self.match_title(video, phrase, media_type) + if self.settings["match_description"]: + score += self.match_description(video, phrase, media_type) + if score < self.settings["min_score"]: + continue + cps_results.append(merge_dict(video, { + "match_confidence": min(100, score), + "media_type": self.media_type, + "playback": self.playback_type, + "skill_icon": self.skill_icon, + "skill_logo": self.skill_logo, + "bg_image": video.get("logo") or self.default_bg, + "image": video.get("logo") or self.default_image, + "author": self.name + })) + + cps_results = sorted(cps_results, + key=lambda k: k["match_confidence"], + reverse=True) + return cps_results diff --git a/ovos_utils/waiting_for_mycroft/base_skill.py b/ovos_utils/waiting_for_mycroft/base_skill.py index d218a9a..41b4a99 100644 --- a/ovos_utils/waiting_for_mycroft/base_skill.py +++ b/ovos_utils/waiting_for_mycroft/base_skill.py @@ -465,7 +465,7 @@ def remove_voc(self, utt, voc_filename, lang=None): if utt: # Check for matches against complete words - for i in self.voc_match_cache[cache_key]: + for i in self.voc_match_cache.get(cache_key) or []: # Substitute only whole words matching the token utt = re.sub(r'\b' + i + r"\b", "", utt) diff --git a/ovos_utils/waiting_for_mycroft/common_play.py b/ovos_utils/waiting_for_mycroft/common_play.py index 90fc28b..a6d6ad5 100644 --- a/ovos_utils/waiting_for_mycroft/common_play.py +++ b/ovos_utils/waiting_for_mycroft/common_play.py @@ -12,10 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. from inspect import signature -from enum import IntEnum from abc import abstractmethod +from enum import IntEnum from ovos_utils.waiting_for_mycroft.base_skill import MycroftSkill - +from ovos_utils.playback import CPSMatchType, CPSTrackStatus try: from mycroft.skills.common_play_skill import CommonPlaySkill as _CommonPlaySkill except ImportError: @@ -30,10 +30,9 @@ LOG.error("Could not find mycroft root path") raise ImportError + # implementation of # https://github.com/MycroftAI/mycroft-core/pull/2660 - - class CPSMatchLevel(IntEnum): EXACT = 1 MULTI_KEY = 2 @@ -43,38 +42,6 @@ class CPSMatchLevel(IntEnum): GENERIC = 6 -class CPSTrackStatus(IntEnum): - DISAMBIGUATION = 1 # not queued for playback, show in gui - PLAYING = 20 # Skill is handling playback internally - PLAYING_AUDIOSERVICE = 21 # Skill forwarded playback to audio service - PLAYING_GUI = 22 # Skill forwarded playback to gui - PLAYING_ENCLOSURE = 23 # Skill forwarded playback to enclosure - QUEUED = 30 # Waiting playback to be handled inside skill - QUEUED_AUDIOSERVICE = 31 # Waiting playback in audio service - QUEUED_GUI = 32 # Waiting playback in gui - QUEUED_ENCLOSURE = 33 # Waiting for playback in enclosure - PAUSED = 40 # media paused but ready to resume - STALLED = 60 # playback has stalled, reason may be unknown - BUFFERING = 61 # media is buffering from an external source - END_OF_MEDIA = 90 # playback finished, is the default state when CPS loads - - -class CPSMatchType(IntEnum): - GENERIC = 1 - MUSIC = 2 - VIDEO = 3 - AUDIOBOOK = 4 - GAME = 5 - PODCAST = 6 - RADIO = 7 - NEWS = 8 - TV = 9 - MOVIE = 10 - TRAILER = 11 - ADULT = 12 - VISUAL_STORY = 13 - - class CommonPlaySkill(MycroftSkill, _CommonPlaySkill): """ To integrate with the common play infrastructure of Mycroft skills should use this base class and override the two methods diff --git a/setup.py b/setup.py index 812eb8d..5105366 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='ovos_utils', - version='0.0.7', + version='0.0.8a2', packages=['ovos_utils', 'ovos_utils.waiting_for_mycroft', 'ovos_utils.misc', @@ -13,6 +13,7 @@ 'ovos_utils.enclosure.mark1.eyes', 'ovos_utils.enclosure.mark1.faceplate', 'ovos_utils.skills', + 'ovos_utils.playback', 'ovos_utils.plugins', 'ovos_utils.skills.templates', 'ovos_utils.lang'],