From c8627be17136a3ad23f4200b9523fdc25c6560d5 Mon Sep 17 00:00:00 2001 From: jarbasal Date: Sat, 6 Mar 2021 13:22:33 +0000 Subject: [PATCH] feat/better_CPS --- ovos_utils/gui.py | 454 +++++++++++- ovos_utils/playback/__init__.py | 5 + ovos_utils/playback/cps.py | 648 ++++++++++++++++++ ovos_utils/playback/utils.py | 25 + ovos_utils/playback/youtube.py | 99 +++ ovos_utils/res/ui/disambiguation.qml | 171 +++++ ovos_utils/res/ui/images/Sources/Bandcamp.png | Bin 0 -> 44977 bytes .../res/ui/images/Sources/Soundcloud.png | Bin 0 -> 10611 bytes ovos_utils/res/ui/images/Sources/Spotify.png | Bin 0 -> 18433 bytes .../res/ui/images/media-playback-pause.svg | 67 +- .../res/ui/images/media-playback-start.svg | 67 +- .../res/ui/images/media-playback-stop.svg | 8 + .../res/ui/images/media-playlist-play.svg | 13 + .../res/ui/images/media-playlist-repeat.svg | 2 +- .../res/ui/images/media-seek-backward.svg | 8 + .../res/ui/images/media-seek-forward.svg | 8 + ovos_utils/res/ui/player_simple.qml | 309 +++++++++ ovos_utils/res/ui/playlist.qml | 170 +++++ ovos_utils/res/ui/videoplayer.qml | 205 ++++++ ovos_utils/skills/audioservice.py | 204 ++++++ ovos_utils/skills/templates/common_play.py | 127 ++++ .../skills/templates/video_collection.py | 270 ++++---- ovos_utils/waiting_for_mycroft/common_play.py | 44 +- setup.py | 1 + 24 files changed, 2597 insertions(+), 308 deletions(-) create mode 100644 ovos_utils/playback/__init__.py create mode 100644 ovos_utils/playback/cps.py create mode 100644 ovos_utils/playback/utils.py create mode 100644 ovos_utils/playback/youtube.py create mode 100644 ovos_utils/res/ui/disambiguation.qml create mode 100644 ovos_utils/res/ui/images/Sources/Bandcamp.png create mode 100644 ovos_utils/res/ui/images/Sources/Soundcloud.png create mode 100644 ovos_utils/res/ui/images/Sources/Spotify.png create mode 100644 ovos_utils/res/ui/images/media-playback-stop.svg create mode 100644 ovos_utils/res/ui/images/media-playlist-play.svg create mode 100644 ovos_utils/res/ui/images/media-seek-backward.svg create mode 100644 ovos_utils/res/ui/images/media-seek-forward.svg create mode 100644 ovos_utils/res/ui/player_simple.qml create mode 100644 ovos_utils/res/ui/playlist.qml create mode 100644 ovos_utils/res/ui/videoplayer.qml create mode 100644 ovos_utils/skills/audioservice.py create mode 100644 ovos_utils/skills/templates/common_play.py diff --git a/ovos_utils/gui.py b/ovos_utils/gui.py index c5211d4..054edcc 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: + raise FileNotFoundError("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): + raise ValueError('page_names must be a list') + 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..aebee2e --- /dev/null +++ b/ovos_utils/playback/__init__.py @@ -0,0 +1,5 @@ +from ovos_utils.playback.cps import CPSPlayback, CPSMatchConfidence, \ + CPSMatchLevel, 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..cdde7ef --- /dev/null +++ b/ovos_utils/playback/cps.py @@ -0,0 +1,648 @@ +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 CPSMatchLevel(IntEnum): + EXACT = 1 + MULTI_KEY = 2 + TITLE = 3 + ARTIST = 4 + CATEGORY = 5 + GENERIC = 6 + + +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 = 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 + + +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 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 [] + + @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): + uri = data.get("stream") or data.get("uri") or data.get("url") + skill_id = self.active_skill = data["skill_id"] + if data["playback"] == CPSPlayback.AUDIO: + data["status"] = CPSTrackStatus.PLAYING_AUDIOSERVICE + real_url = uri + if is_youtube(uri): + real_url = get_youtube_audio_stream(uri) + if not real_url: + real_url = get_youtube_video_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.display_ui(media=data) + self.update_player_status("Playing") + + 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.gui.release() + 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): + # All Handlers For Player QML + self.gui.register_handler('better-cps.gui.playAction', self.handle_click_resume) + self.gui.register_handler('better-cps.gui.pauseAction', self.handle_click_pause) + self.gui.register_handler('better-cps.gui.nextAction', self.handle_click_next) + self.gui.register_handler('better-cps.gui.previousAction', self.handle_click_previous) + self.gui.register_handler('better-cps.gui.playerSeekAction', self.handle_click_seek) + + # All Handlers For Playlist QML + self.gui.register_handler('better-cps.gui.playlistPlay', self.handle_play_from_playlist) + self.gui.register_handler('better-cps.gui.searchPlay', + self.handle_play_from_search) + + def update_search_results(self, results, best=None): + best = best or results[0] + for idx, data in enumerate(results): + url = data.get("stream") or \ + data.get("url") or \ + data.get("uri") + results[idx]["length"] = data.get("length") or \ + data.get("track_length") or \ + data.get("duration") #or \ + #get_duration_from_url(url) + 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) + + def display_ui(self, search=None, media=None, playlist=None, page=0): + search_qml = "disambiguation.qml" + player_qml = "player_simple.qml" + video_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) + search = search or self.gui.get("searchModel", {}).get("data") \ + or {} + playlist = playlist or self.gui.get("playlistModel", {}).get("data") \ + or {} + + self.update_disambiguation(search) + self.update_media(media) + self.update_playlist(playlist) + + if media.get("playback", -1) == CPSPlayback.GUI: + + url = media.get("stream") or \ + media.get("url") or \ + media.get("uri") + #if is_youtube(url): + # url = get_youtube_video_stream(url) + #self.gui.remove_page(player_qml) + #self.gui.play_video(url, media.get("title")) + #self.gui["playStatus"] = "play" + """ + self.gui["video"] = url + self.gui["title"] = media.get("title", "") + self.gui["playerRepeat"] = False + self.gui.show_pages([video_qml, search_qml, playlist_qml], page, + override_idle=True) + """ + self.gui.show_pages([player_qml, search_qml, playlist_qml], page, + override_idle=True) + else: + self.gui.show_pages([player_qml, search_qml, playlist_qml], page, + override_idle=True) + + def update_player_status(self, status, page=0): + self.gui["media"]["status"] = status + self.display_ui(page=page) + + def _res2playlist(self, 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_logo'), + "track": r.get("title") + }) + return playlist_data + + def update_disambiguation(self, playlist_data): + self.gui["searchModel"] = {"data": playlist_data} + + def update_playlist(self, playlist_data): + self.gui["playlistModel"] = {"data": playlist_data} + + def update_media(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) + + url = data.get("stream") or data.get("url") or data.get("uri") + 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") + + 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): + # Do whatever processing here to play song + 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.update_playlist(playlist) + self.update_media(media) + self.play(media) + + + + + +if __name__ == "__main__": + from pprint import pprint + + cps = BetterCommonPlayInterface(max_timeout=10, min_timeout=2) + + # test lovecraft skills + #pprint(cps.search("dagon")) + pprint(cps.search("horror story")) + 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/disambiguation.qml b/ovos_utils/res/ui/disambiguation.qml new file mode 100644 index 0000000..85e0246 --- /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("better-cps.gui.searchPlay", + {"playlistData": modelData}) + } + } + } + } + + Component.onCompleted: { + playlistListView.forceActiveFocus() + } +} diff --git a/ovos_utils/res/ui/images/Sources/Bandcamp.png b/ovos_utils/res/ui/images/Sources/Bandcamp.png new file mode 100644 index 0000000000000000000000000000000000000000..c73d835eb10101a19018481b43832d6cb14ec92b GIT binary patch literal 44977 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

DF za~M?K8C^nAi0=B%XxwLZTlFh#M0*5SX<{xfvdmb`&VY`m@h+1`ewI97;(VzaKg#=$ zD}wN>LAk^;3DdnLFJpPPDklxtnSq}84P&n3EzQ&ofbT(nsORvj9%^+IVP=)h^&rGKb0Aj{kfVFD~ba7p|oL$mU8|a!62zpzz5mHu&m(D z_;V_$$A4Cso!}&dlWG0WO&ZCnh!i(LRAZ&}Y$N@5*$h_+ul)UDq_6SyBX|7M1eHt= zE8Ly2o^vS&RWnrw+OV^5&dqYoFO{1P7NNyb{3t%WlOe$*w*R%Q=BW>!Jy9hcx?M~X zg^0v^7~syPf^4=^k+>KEuOjr9UQ^J7?6lSqbg4wMEjZj_J0tWK`7!8J>(15wg9<3- z34&lzd{CNhJhf^5f?IF$Bs2*h{@D?8xLdQ*Bp41P4{DN>29getStv|L*z_hoDD+hH zfm0o{#TYV1H1y;SXVyN&t=Nu&3dBDU4$=aS@|P!AU9hfcr-uCL=0R6%qOuS&I^X~k@j4$ zFI4&gr@so^$?2+Xo_MAdALz!yCZGECEs?CWbPO#SuZ*wRyaUHo_+J|miHBCucb&?R zE+edNjDBeuz9u~BT|jS)Hj*dnD5Y?+^!rzyO`Cx=X?6Pebj0~(or+%zlOx?hG(f}C z(3J)CP0G9|?~*~k^7=m>U{kp_t@dDAf0P!CSjYvi6$oO$X+soAu0C77$;^lYl0}#{ z)5Yh!B?vNB0CfNl!xtvHCD^?v1zdJrf*k>pu`nLkF;kDe(LrUpPRXeT^lf=GMa-z< zX~&%wz!rwT_Zv=lw~W$;txV;e~qFdqrBf^h!XJ?#wJp@a<9et)<|O8MCj832?? zVjCb%lsokmv*zi@N|Wv%+K|Q(Wfvn*w*O~kt~TIo+pi=U=Twr$jLa)t8X~SP%BIW z^PN?aYXL})D3vVgwnXjDUKuY>#11nGGKq9Dbw}e?YxTL;TQ%PGY$$+ClBUEnYqwKF zi7R!oCZ3V2#{kC;1yZ34@hPW>nE_q!HIA`&Fq>P(x?e>j+P_UqYf7I1XW2xbi6EV` z$s9I^a4Kk$czL!SgYBzk4HO@cViAvW3gks3JNZfQS|}HHAn41*#M&V&Z2cl7aRu=0L7HP>=uG zo>r>Xy1^hm?{DXX12V%lJRROisSZ;qoy`k}cb1Qt6CsawWm5eOoXqmD3eYQJ>eSo2CSwF2hB83F{m>HU(9Aec*>Ke zh*aAj$}+g0YBRcgV#S)BK|bIh)4mi8T+br`W(hut$GB1b-A+Am z0pZ;5Q0f>VhfBA{Sv=)8LkINtrOvb2f@YB3%Lqh=PHf>qM?4ma`8#2K*Zs8rgh}z zQvsCf_|&O5mXZh9bY&r^Bbi*G_%7s^ZYk!LQQRGqW1o8GG`xt`z&EcfuSM{y1mnCfPJ*!n| z*!8Sz6OQLO6qQNt7m)|L8Jx4gTU+G_aV;37x26F2?a$Yhkpd>Oje8qNlfG42Bij?( z1*xD*g5IznMr;x|4XaaO3v{@2uBbA5Uq^y%*2HBHPY9*-hb^`z8mNX_MXVznqPt3{ z6{=6JzKs5FXqYvBuJb;ddkBzt9O1x*^nJ@=j+QKBvy9vzYTQI?z|cl%74i-<25dBF zt=a8F#kI355As{;>pO5sSE$E393s^Xw*M>HIMR+81Up%IhJc()Mt)V*aWkw3yw1r` zJW%Tc#v-SQwwi61`*Be^E@Ux;^sXhFP=H4EnDh6HBBdNX@9+koudX7^{w=W{9 z^g3U>Ga*`-fR!M)6(H-P1>(`MSk5GLY#Ruh6THU#*u%|Y`DCS$J2 z{Vf-!`F4yWbS=m{?=%pfMyweGoaz;do^R*~FF5u&dV!ZpC?3=6^q9dn)NdV-M2GnF z$zZ(7^9fFs5=5m?A#iOnO@liUTM#X!SL!`;-y=c@dN<=BWvulV3_ZB6j}&}2I!ycO*2Gmlvj$A{z^UBWc-Q!#~*z7i4E!(-t;GjD1uU!A6G6*KQvTCCQ~3ip6VO)+O6yS__^N(a{R2O8+rg$>6pG zDWheF)M_y;Opt#l?OC~6@C}>k!H7jof~Yh5w~IK>R3$!ubH|()h+9qZ@%1XNZ}2>C ziMuvo>zh1SnkW6t%q(=GX5&e*^42dA8p(=shs(OK(*;;vl4Lccj7d)yR{jmUr<*V2 zI9af!QZ(AS_oIPRG?XK1VtoN9wC*x7`RvVu*t<(6WGk>5@%2hg&YLElVDPur(6Juy zq}8Y!-P_yLgy*i+!<$A|OJv{77f(re+#ZeWGAP}1R6;6j+@pFm6Pk*M8n;WpQK#2! z&p(t#5S#_Ye=ef#gJ-Opq)$eU!MtUzU%{5(#qU8!AjjnPcr)pW;o1i0K{1l31$F{q zbb7|tEc|AAqweXXoQp%2{K<$5PB5`5t(~D1@XXef?1Sft8C`_Ua-hZqR0IfN)Uyp3 zr80WtYFnA0|Zr6NSEBi1r&^XMX>mob=!6};Z+XX7!b6DSQsoyKLB-8Pc*n7uC)7@m3&|gu4+xx?J*V&tO=9wkY(*0eiAc+URlK~o7sl5 z8WGOJIo_RIui2eZyd0r^{8c7a^v*!dMMY7jDUZ!hEGiKZ49I}2W0b1>AEl3eB18=q z94PF$56D{7zSW-dWZHZVI`B!4?5_=*9f$If+KoKjv^~mk?@#nEVcRYjf*5)*h8CM_ z2tvr`F+^gb3+2?v&2Pq!Q2)mlA)977+c=foYM2{){=A2`QjC`%OLr@<4zRAAtT@fM zy_K$n&5>3k9ueEbdtKdYOjg@shNw5yN2q3cW~*a947Enh zBgfUpy3#A!HY%8gS|`G-3WV3q48aQF$7Yu>&B7jnEU6d@a{m7H-{CH8lU{qy1d__l zIKgSNM(^D|RjOxYdj5#P0Z1c1g*rbut5>XU`S5E%#JhEBVJSGy{><5xLqlhc$#+#; z%zT-hk4l(tRK3?+MPwmzWU{2T6dsTw5HY}88AT&mWmQ(ZJvPAJljojU*Ut46)5|HR zShHouq+u{C0Y84F)L@eKlD@zyQCs*btE!$q{Y^K4D@PnBcL_@U%Ydwkh+?5zn#UG7lASU2>uhacm zWu*o9SV|24QES;B*JY31#oKVR+rd`uBXZ6C^ep<;o+%Q_Y3&8;F%@`^7Y?G{6p0^vqEoFn-?;3lAw zNYH*?-`P@B&4vl0?eg6swKrE1t@z_*`Nfwe4Awzyw%m~*y|U-i*ymZT%LChpnC$H< zaqmQc-+jmqyG=pI$W{2s!jr`#684Vd48G@3rtmOQHIfFv#@SmTzoC$oUn3JQJ-gcIMGCfo2LCf|gpR};PnP8{bkBsajm&T*t$=74j zuZBVKx8uwv(+uZL9J;So4Larq2w?lN$_Po#)(1!x_C1432YQ3HUp5bG55)9sOLuq8 zw>l1P+uRz>>beczg%np5MX~fpbSxZa*Rp(?(4d)TXqPb?hr%+--dbtLibA6L$ca@F zb9c|PP7|)E&);+U8HqK6$?(TDGDS|YO)@XN4I-gYw$Ht7hA4yTV>1E#;$t3#GIbHy zNh2#@22?7_zecC=&$3aRRI%*4ldpkFUcND6+lG>5i8qxKLbrQOUbL@L(ym~&%D%<* z@&5p_5}*iY4{NoEL7Jw)NM|uhO+UO2Zo=CP$Qo|%zpU*MZmH;c<=IdYN<5Pt+g1VA zY^pRQxVh1UJ|j1|gk&v}8&}3Xw3n+)=)}_3m09bea^}FjKa999Ug~t1s(FWD_<2V^ zHA!zR1hf`WEfzsfr>k)z&g*NY0sH4+`>ZoNDbJf!=jKu>5I>Ku9Qe9>?*0}@P9y3* z!0F0J{5o}N@oi^6?MdfACbvQ|3R++5%or^G2*TV_P*$3At_9_!ZScs>F`U0Kt3e`-$L68%mquZV_6U6VxGy4yV3d6Ls=w9wd(M^Ts=Wv!AWWGt3 zqlgU!|6I#(LR_PP2!Dr~9&bIPvo|y|>;O!ssfC2}bSxcb9(>5@Sm}YBR)A?cq!)!D zcXsBQ(O)yl=q(1d*(N5=->?W*Gf~;{MJ%g1cR~yTr$-N*;_G?Tnv3@L7 zX@}QO%W`fs89>)3-R#ZH_%8zmegL&QV5ZGIUumoT#2ReNw>Pcsn%=HQfE*9OwMdvFp>Nv*4R&f7UpHo3G;$~O*os<6G>VVrc`PTwn zVMPi~^}l3U0%L^mGHOacA3(I%%v7gbXpw_+WZr^Ro(Ob1OM;bg|eM>4il1 zcvm;SJ&}{cX0%^64W|u}B!s)qlv>(6#E3MJ7+ILh8#lj44g)d3134>Q3a_nemIhdf z$=fuIEOWzZt-|JU+;rPF>Z0UImAB>uDy!I%kix#(#lV=BOV-b%DK;oaGG2-NVUpMYx2XV@}yDH&140 zY?RBs1g9m{mwFr%w-qvbUe@)zOzm7Y-#K!#|NSJ!F5Z8#lQOUZ4AQc+T6|Um^uTvo ztgO0;uO*w%VYV5VEmuiG$|hSCIQP^w_7dWE_Mf&NuQ0fSsO^}^b`g*rSA<`ZtKAB3 zQ_vVM9o4PuY*1UIZ9x~(x>_L(gY$x;vAGX^ljKcp!|r|)>HAC#@~LKms4sDba2jbw zBi<$UTS`)&Dy&*Htk_EGPIy}zOn?@xu;3PZL21z^0)**9P|PnRx7C+ryAIwC$rb>% zVEf!~_C0hxQe~s_0GZY*OfTrWp9grhuQUX@V z^|$AzbeWNi?abxb8*8G^7qKYHFE)Cf<*Dy&wg(xl+HsG+N>TeTSq9}V@@NPGm5~bF z!FiME($I*i;%I5_TdXe(7yX1bA<)aAmA}#iS4piMX-K##$a=nzA~Exck3uV}!9;Xz zO3_RWqukSR!H}IY#*UH6@pbK#z}k8a-@gta)D`|V%6ZvhHDexN8J~VFP&2j{@;&?USGKdZ zY4ePAT19u`BMF@@C~(n~3^hYZ?24fFne>z;&)2^)1gqnWaBT}sg*oVAwz>%X*29zP)N#uT+0ng^2 zz+q(Ae#?rNAjM7yMIQ4;^a+~f6dvRZ4EALnEmXM)pu!&P@4s@7L#>@`NP+3of!GjE z9MUn%7ioeM9qdcU$2T`p4q6s`Q^*3vBg>dR*k-B-5rlaI%Pr~yR0mv^Zl+%G zK<8)aDyfL>vE85m=LS(v^@)}WW;>kTsj5#4>K*KhA+Q4CKm94eD?55p3Z|$NBX9;g(%%l8y{#FuU6|z8q(}a zY1k{cllG{z`O)M`WFkd@JWx}IOgzb_?{$TjAYOCvqh9T@jH>`j%$WG9F_Dved!yrK zY>OF*=#1mSOvY6LwN^ zBnr=HNT`QYRfJ@%02hwc@s5e_Qd||<@7CDd@ldMiUgSYP16x&M6fAO&0K6cW1A^FO z+2uOe*R0D@8vP|1n#gI)qt)tT-|jtQ1@(7vh)91!j$6T`-sEgbZ$}$`2q279rH9TI zQu{2|Bs!zQ6XL0L-jn9I-U@)3lU7YRF@ZLLa-TGbkXBPJ@XP86j3|obedKEXt_Dfo z#mjxaRg<}Xa=0nvMp-$sZ42@t?7dBQZC^e`P=LrYz0%wfw{o^5xE)2mWU4MKR%Jh# zxMBIf$}HBh+#PY!M#go8E0mK@Lc0e;DKkMXDmtIUwZqgi-EJ4{UM=RFDq(hH*6lQ2 zzV^V#j%m|ch92%-)LfZ*r8u3Xj9I?T6S_4yDF+Zh*#&Q5c|Yl4X(PQ$4$vYb9)zqW z&-XLC@5T-|?Kb#(7b%5#C_h7sz2uwex3OvXl(Zp5Qmv_ZO{rR~pdjk}a&Dnp@lei& zaLLphA0+&4P)W{mSkjbD=5di6R0(dS>D}yfq0`HUX(zKYS*ule-?S#!DqzCH%18vA z>Wq<7EHF0fM6UeScGz2Oq|u&rd%eb+P)@mDKwNdjL&pzNk&%r?H(@Ej)26N9M)NR& znVZk{C+`jZMmc27iYbEHG*by;=$l3rp9JWNWEF5IKDee-mpG7KUI4=cQ9C-qaF4Pn z^UZ|X?Zo3ZE4~j*wp9n@`MnjzTXgM@9mXYlw=;$IY!Hyw<2ImFm8U^Ooe*2ez9TD3 z>C17$7xZSvw7RXDm1W`gNCGu(g|_U)z=KIT9Bm>|o5vm~rUsUzC&=5}9!>9Zu@tuW zMpq=d^U+0C56j?pZ)egkkX~LBRr*jN8G5UC0T6`aP?2n*SF+R;BC{hXDc{8tt!ayG z-3#+b7b<5x6#YAIui3Q0ins>ssNDk-w(|AC#`3e@9wk`i6bOGq?!8-S5gr2JrV(Q#`n28Sn-5_xmxrWV}?EYp&pm{^xh5Iu6y z;mTlS!brvz`PIrK;7{3*5QPolSRLTQR5VFm(jPNY2-OQd*5}NUI~8vM3}6OQ&kF?H z*EHMG1H<*g9{FUkc-O472InP)8DK1g=M~9nV3Oe0-JNAcY6(;OdB%yAPd<)T2&ab- z*C92CBMVH?C&)Jv0Y`>fhvoHDX*3DSDr=gt7R@;%xZ(G&AfzSz4e=~v@e8=Oi#0Zv z(1S73)0VhCW*yH;+tga@lvU0}Y^*R@AY3Bo#h*)z8|NNf)KVO$UgVJN@F-Iky;zWc zj4I7rP^8{}Sz|vm`cNV@RN!ruThPf_uhdx{EL&216ag)4D_DY(%R)Z|hbOLMKa!xe zl{RT}11npc5R~0w4Fb~eh^b)G&JG=kaf<>rukU3Wqop1s2|HtS2vqfj?YFNyIX@@+ zT@U(HP(@}C3?zZMMA5&n&U#ABo^7zr^JY5S;nV&~8F#~jc$>`|)j_TMNAS#}IBFOb z#g+|Lx!*o*ye{u{0mf!l;BjstUPV01W50HVdezJDbR-c=_qv>);x{YtWY6qGWunId zDidmJW>5?Re%{M2t=i7}^~W3fNoGN!uN^!YI5b|&fbs3ZHYRc1wkPJ zsa%fpTmW8q?Hi&n-~3IL*gjDj;b4xY8@1dFAD{4@UrnEbF@OGFzRh?Yx8wA|a3Dc7 zi`3#}{Z68P7t>=HY2#)4!Gi&=o62|EQml%pI01*0g|y57xoSNrBt3+RRhSP0oK27E z&BQBnrni2;DR|yV6L@#3+lX!<%{CQr@Mmi>w6qO2+2M(2q3;(j$}zX_<{YLIV4iA!*k*hxn`b+2zn>0J*r4x41;hZc_jUI{yrl>j6& zD1BW$@BD4aIRO$o^hd2R5xNHtPf1xmY)ctL>+I$XJe0m`O;!b+G6Mv%fe|IT{K294 zoCLDQ5umsXRq$=A4!oTdx>>5%M7c|V3N;{C)QSl*qf{B9`F44e=B5!D!ppiHxFIx3 zkE&#{PFpd)oN%M73mX=&RGui1ojHecu1SnGq)=AAttQyUhb|%XE-&}h;X60;il4d1 zXg+zip1$HVQF8sW>Kj%VuS18LLa`H61$R#~1jhV&vE?%&uaI~mwPEM}XHMvQ3l$%0@~z&2gf z!EfxyigcQ9j(eN}fT&!W!J(WLczU~qXnv*JRxluIJL`h@D5TI0X-58`B7o{b2TYn5 zr5027>d;n_MChQCNYL`loE6MfE?9qwHGKi0S&^=t$M7e7E9fy2A_oX? zw3cNsybUTG<<&VF;PeaIcTCQ(Eu0l#o8?Ww4$RLAM7TA6?Y5BE$o$V1}+4J*UA7ymF$tGx(mnTO=Kj#;3 zdT5M$@r*P5=PQF=iRJJhRXocPWz`TG*)~BNt;$8cr8T2(vc`W7ueKI^qvqCsz6G%F zGBrByc$W#`zT%vv9<;Xt8#z{Tz{~~AFJ52nO+T!{Nd*0gPtOR% zxF57a4bh3GvYTT9eQ{ESovlYM45cvt7K_H-iwSOdjxUA0Pk^Lh#?7;cN5G3A8m1#4 zi8cbq=x!+pK9RU+3SV`|u*~g<;z@E)ooD*`$=Iwpv-s0%S&Oxp zTI+dJQb2}vC#v_#x)objRxra7&Gh+$jogJ>CKhH|w!*WEd(iXA9G&qTM{>`c%Rv-P z(~{_+Do;pnV~U>0yTZ-%z&}#p&XIxh<^fR0C!jTDg`kp?)+1|k!RatJzA!y&Oa?Rm z&1X8A)MPNJv8b_LbZ61{jCb7vm;29(qS{=j%C%-cHyXRPaHG zHEYGme(u?q9xG^BF>dD}SN<&UT zlf|p&@DYu}b_y>)c#H#Rlo%CnWmR&Pd|qQKvARDmk&@r6RhFJ^4!4k`rpy><@p%Y` z>bzKK+m4j$U6y;C`ve3+dGQLddJa9Vf5 z+S~miS1T=ykG>WU#Zao(Xy!Zgo!4vwHpo8?ZblP71#PDw@_`1=$o>zG19I5rA?i*{ z&EW;VWg1Ma?JLu~66V%boxrzdURVOB7bibmZzX-lU^d>lhwXk7C8a$!j{E^G685*4 zeuX(ww{I~xWiD4ys5kB#LoA(}7d|Y~#?)cT_)3(cemv8ClP&w5jQ?gbM*qKUd4{<9LPV2H~yqUb#=~t6J zr{xAuGsJ$}w{0*Cj)?-S9ofB9#Y>8s)_uvV%DL>1bv7xARbuKecUl zi+4y8szK2?pdy1(xdlw6)X-vU@2KZ+Y~ity>xNr=F=s%-q*b8SImoI}R?Dn&8RJ}r z6Iw2BgwLr`!5c1)rCxWb5ES`2v(G&_NH*X`yN;E{88P|t4 z;fi|lz7=iZjo@*!2^+E9Opn+!CorM8;;>1Qdut`gzN*}hS6R60BNpuRgYcu>lHh>5 zih~KN%Q~hsyW*>LZ*p1!E4R( z^!rs~{BMqJ(x?oTHGOo*TBY7Pn_|{moIq@ob&$aPx-GwG@JED|vZe-s$ z{LTd6RJQ0VaM$zKcy};evtDC2Iiw;SR>l^Rg$ReYT=)WAZJpTmaI%F?NRu%cH#r8Y zV4T02vqM9^E0uM}v)6dxlQxAP;ZhYvsj*m@Szsbsn#iLi!PT>y$7JKRw|cSO;;?HF z=AG6m>I!-gcVD!F7k&3zIDDPnPcnAYm(2v-K(wtbR(Dapq1wTu1A zc=%-rm}FG_T+8Wp+G`upNxxdRMdI1=j>+G?0q(y=2wUAa2Z;(;eFd?qv_MSo`BC%t zuehy!0^LhHwAxR7N?o0Ie(>~in*St;h`iQ zy}-_nU)d3-G56J;o8J@xZyz0AS(Q(Ji79i@W}53*LplS)jRo!JnpQ2z9G5~s?%0Z% z(gLF2RJ?jdNjY;fBnEjai?y?KC@anF)%qexbnf^eq%W#*T**)^CkJ&IbTZ=J_L)1? z*6cB~Y8m*>a6Xbv1G@eU^{3zXa1$OOX8CvJER8n$D_@9e={)m3+;m6EvYsq&dwdy)2!4ng`WP$YUfpX6@J zJ$t@-sJ@Y!{(Q*p98Ck6jflaHdk{{=c z@t-_mTPA<`Qt-CMFxAWo=DvGw2Iy%lpxqbSR#{QK+y~czhNnUuv?4JIg-VcyU2p2Zi;kJ`jJTq zctBb&025TE%#N^yL}Vw%4A(=aqpIT`>8k5XZQ+z(>2`tnvnYogQat_i;P zg|_6CKark1WdgcACG3Mi(1oCfXf^l3)6MgJPNdE_UxEdB)harKZ}8M>X=C|uta@fi zi0Z*a4QR#FU$!za4EteBc}%Qye-dh3G3@fs3zaqhi8A?f_f^fDMU)+e?JIO&b(PXo zY4=srzbTwPm(A5JKGb?A+rCb;@;B0Wz z4-uX>&^r}8rbp!*i-=_Lx`%rB1sd$}mPo!bTBgA|m(9rul=!Lcp6V+ytF9F5jc zdu2&ssK=o2F#LPoywY+7 z`z9_L`B{687vsMjbPR@nJPrWgns^EfiAi3l@2!=+*9t2t|5k@6s;`O_)cfL2YxY0{ z=ds?OBWts3Y{s0!wl1buy-@}pB(w*T)ZW3xGq!>z=`rK7E@!+;-{;7hB_-V6Y5#oB z58}9LsR+%Kt2Py}f}WOJfpa5P_0Xy}9E9>1?SA`}Wxg$qOrI+T51HmN*>Pr6M9OAU)!q1iVFeiMUmlS20!$-yQprQ&ijO z*&VQrgmzr#oF&+qGr_`JqWmxA_N!dJ^`5bJ2g%s5I+{J>^Hv4}vfWP*BHs2$>WIFN zt-vyS*tZCY;J-guDiM0{JW^&(tO~WBA!Y;`1??(8DDG*LM9wryj*7~!p-4`6%M)4? z$h<7cvAuIvb?%YKLv%q)rBDAD#l%;@-^?(F4KhLG-HSG(CLi;9*v4)s$gW|w_F7@_ zC&;^a;Gd>AW86^FpL~0WuHILF0QJhM$GPoP72@=PP&oBjVZJJuaT3)t`0*CAXG8ke zknP1hSMjgxg&Uon-39bg8MDYZgpmWKC@Q#(=TbRiOerAp*^tMy*;=><5)EofurC#TR}~7&@2j*nNSz|5 zmyn-}s7!yJ*Dni#(#ZSY{q}(Ssq!o`WV9h&EFcnp8jqqtBOQzI_`cv5{py@0M(BdG zJ%zV3A0t1m;*5SOWC)d!4e<*Wf)`J!$?1Bq-4msWS>px(COu)UCX?ulKAwry(>wYi zxQ}dgD;{ZZ)`OJ$Kcp$n(zl{M0cN9#)3|jR2GY0}*`=#+a+g8;jAW|nywRznZ?eR2 zvTaf|Z%XKJY&W{xQ$CgX0qF7}3c;&~t>%U@UGe+Xb1}dG@fx{X#@JX#9}JuEZsW`; z8I;^`cj<-zJ^OqhBNk!Rcj3P6Dq?F@8X>&Ozho%tGY!6d9wBDd;d9BfCrY4Qh6R)j#i>PtfMmH(^XsbWtu(Bt-lV@h9g$Lta zG2l+bt%v_%j4O6Fyw#^J`K#?zmsxa7tiu%Y;^SX{5EXBBw+RZYLgps!B2f4HiY_YfEW$0V6 zm||t+e5g2~v*CRAEn^G8cZv^!Qg6|Xzq};t-rPQPm53h_d64Ufxrem!pUQ3##Nc^$ z{#;(p@-a!(S+?Fn|GXX+Al18Xsby>CjEdHB&?A^IgLQqxzR$0Qv5XTxLi^GrPs4|T z!FouO60%@Mi7=y!Q-r#l+|Wmd-YB*b+=|@z_Xf*I#`-%upRSV2{6E-~7_x7RG+(o7 zqCdiX_zoLC1)G(0f*3cON()8mZ$y-ay{L*V5-I?6hPz&WhjQKtyWjL^{(|ZZm^}Qh zv3B@<%2se5OoW-|vmRTnKeTP?;@V2GknTO%8qrpzI2 zx|m~9ilxWzA%^4GyaVj(y_6MYz%UX0^KH$g2m=(+)Pf@R!y~YGj+a>S)bO}>sMipg7|o5E4uDWmnz+`)zR~szqhhhReUp>* zFesMGsCf*o+y=?Qh%p+;t)1q%HJ>Z-dRDbLKC68T-L@raAP{)OvDZ{_ofj$ns#Bz%*&6vV8=&pkoZ^T^wdP2u}HP8yE&g)j)t}a^U z`Rsm_xJiy1+0ph0a^WzCnwfd53J*1=H(0XB-E};=oVI3He4nE+T?3?gu-lPR-g_VG z3k7HGc3RM8PX!xsO|?4p&92`GLc?&{2@E>mMGKe3)uM&|%d=)pZ8;Ay%a2Q{Kxf4~ zUaEuZxW*Xa#etLaS6X$capHQ8ITU~aG^$BEyV1yZFQr&Gz$+^qU1;bgSxTfn@OJ!g z%U($DGP^%T7_Y3nKV3-Pu(NflO~2!6zL3wX)2w&Xpx=Q3Qfu~MEzLquZr@ubByqMQ zF1j_R@NAA(E$baIxBF(1tA{OSjosPYUKiOqxg*PAArZ%3N>U9AaB{jJcsW3Dto6vI z0yZuQHk&epLVSvlA62s?_M)9Wa6*NkI_5UP`>vd$<Njh%RqZ!( z>h~m^=wh`c^<0bY7Cv5-xKPZn68R$zzrn>FvSAl+$!22p(ab5C%g`p zeT$=hCnUpV-x&6|TwzaRh=Jp&xsMJU<{52^R+)qN1Z2Z{1&wo}TNyocK-2B2z*)k% z(YV=T&J=!CJK6~6;ye4@OewqgwR3*eO%0gk5N1gA|3}kz$5Z|N@z;$gp(sijl~FPi zvXz~2?b$`d4cV>{mu?y#l~MM%u4G*6+Uw$84P+OWYh71n?se^Tul0NT{(is5!{6?C zpL1U0JYTQp^HoneU3QSXGn;X_ED?(P{J373=313oyUkuYC3sFk;P3N0ehBZQrCI3} z#qqn~x#9{;IeO2YfpA6LA_s$4i4WQGoXl;0s|m4^jUv4RNG8^vj#KeU!Mi@)qYz_4 zuaDx8{1&y2RMmc=Zflj;%50*ty8BuW;103mPi@p&H3XcINNcV1SgGbI?e$jJS)jKn zCrexTb`>d)g!mR$7^hfp!&XSMRXpRbG*9D#`=)upIP3LZpk~}?lJjYdH?8ZX$(}hs zG2!ULV15^d<&~Eo!OC>phl)LzyJ$}2hB>8yG87~*nsMJ?T;W>>#l8U_X7T!y@^+p! zQz0}S4f;S~0M*cjAYWfHOcThr0AwAIo`3(a9gR{4jJML;i96OA9>HMIgF{lG7nJVm*L7>lqf2 zVv)LvzKNJoE{47zGFtT1hJSM3{!PwvT9DEkw~xFXHN3-#f{hnC98f=(rC?SK05)9a z=KN5DJd;4;LWt9hJeXUkLLTw8YS*x??tQ-Ed4QsJWp>j$bCSsa;>J|n&>7!D* z1Fh?CTxaNfmaa?Mfvh!jbLtjZ+iT^V4vJ{p=evI@6dOP87#VzmCScFHr4}de7xDHB zbKRRzPmoK!qy+hAE|AZeTIzlUEoNq^FfS)IPbe0Vhcp_Wt*3v^KLj?k7sp-@AP+gy zkn6L3;5?TBD{=IAqp7}^mA3N==#}n!UpOB5?AjOazh_c?bA7&FwHXY)`0FI&RoFI8 z1vh;K#N>W4)KXbPtfv7pWX=2gn;twhG;f1ryGr;v*JW@ zH@-isW1xchTFJW>I3o>RvhpU$npJ}RUxXSM;@>vt8Xh_+08+1dr)e za>v+u+mN!BCN{zAM$K*j2)194q7-{g{;*FbyAb%PV{e*;9+z7IH|2pnKrgmve@9`* znL_kTzQI{l`7#RQ+@Qfj&x=6I`skpq@Sld49NUz

N9pxtMrF-;l%P-)g$4*~%wa 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 literal 0 HcmV?d00001 diff --git a/ovos_utils/res/ui/images/Sources/Spotify.png b/ovos_utils/res/ui/images/Sources/Spotify.png new file mode 100644 index 0000000000000000000000000000000000000000..17c8193ab8422de42071b1a44a58f7a9cc182524 GIT binary patch literal 18433 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 - - - - - 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/res/ui/player_simple.qml b/ovos_utils/res/ui/player_simple.qml new file mode 100644 index 0000000..3a552e5 --- /dev/null +++ b/ovos_utils/res/ui/player_simple.qml @@ -0,0 +1,309 @@ +/* + * 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.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 + + // Assumption Track_Length is always in milliseconds + // Assumption current_Position is always in milleseconds and relative to length if 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: "better-cps.gui.nextAction" + property var previousAction: "better-cps.gui.previousAction" + property bool countdowntimerpaused: 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.075 : 0 + + Rectangle { + Layout.fillWidth: true + Layout.minimumHeight: songtitle.contentHeight + color: Qt.rgba(0, 0, 0, 0.8) + + Kirigami.Heading { + id: songtitle + text: media.title + level: 1 + maximumLineCount: 1 + 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 + width: parent.width + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + + RowLayout { + id: mainLayout + anchors.fill: parent + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + + Image { + id: albumimg + fillMode: Image.PreserveAspectCrop + visible: true + enabled: true + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: parent.height * 0.05 + anchors.bottomMargin: parent.height * 0.05 + source: media.image + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 1 + verticalOffset: 2 + spread: 0.2 + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + + RowLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.largeSpacing * 2 + + 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("better-cps.gui.playAction", {"media": { + "image": media.image, + "track": media.track, + "album": media.album, + "skill": media.skill, + "length": media.length, + "position": playerPosition, + "status": "Playing"}}) + } else { + triggerGuiEvent("better-cps.gui.pauseAction", {"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 + property bool sync: false + live: false + value: playerPosition + + onPressedChanged: { + if(seekableslider.pressed){ + root.countdowntimerpaused = true + } else ( + root.countdowntimerpaused = false + ) + } + + onValueChanged: { + if(root.countdowntimerpaused){ + triggerGuiEvent("better-cps.gui.playerSeekAction", + {"seekValue": + value}) + } + } + + handle: Item { + x: seekableslider.leftPadding + seekableslider.visualPosition * (seekableslider.availableWidth - width) + anchors.verticalCenter: parent.verticalCenter + height: Kirigami.Units.iconSizes.large + + Rectangle { + id: hand + anchors.verticalCenter: parent.verticalCenter + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + radius: 100 + color: seekableslider.pressed ? "#f0f0f0" : "#f6f6f6" + border.color: "#bdbebf" + } + + Controls.Label { + anchors.bottom: parent.bottom + 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 + radius: 10 + color: "#bdbebf" + + Rectangle { + width: seekableslider.visualPosition * parent.width + height: parent.height + color: "#21be2b" + radius: 2 + } + } + } + } +} diff --git a/ovos_utils/res/ui/playlist.qml b/ovos_utils/res/ui/playlist.qml new file mode 100644 index 0000000..586e2de --- /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("better-cps.gui.playlistPlay", {"playlistData": modelData}) + } + } + } + } + + Component.onCompleted: { + playlistListView.forceActiveFocus() + } +} diff --git a/ovos_utils/res/ui/videoplayer.qml b/ovos_utils/res/ui/videoplayer.qml new file mode 100644 index 0000000..90f3db4 --- /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: sessionData.playStatus + 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 "stop": + video.stop(); + break; + case "pause": + video.pause() + break; + case "play": + video.play() + delay(6000, function() { + infomationBar.visible = false; + }) + break; + } + } + + Connections { + target: Window.window + onVisibleChanged: { + if(video.playbackState == MediaPlayer.PlayingState) { + video.stop() + } + } + } + + Timer { + id: syncStatusTimer + interval: 0 + onTriggered: { + if (enabled && videoStatus == "play") { + video.play(); + } else if (videoStatus == "stop") { + video.stop(); + } else { + video.pause(); + } + } + } + + Timer { + id: delaytimer + } + + function delay(delayTime, cb) { + delaytimer.interval = delayTime; + delaytimer.repeat = false; + delaytimer.triggered.connect(cb); + delaytimer.start(); + } + + controlBar: Local.SeekControl { + id: seekControl + anchors { + bottom: parent.bottom + } + title: 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/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..d713c5a --- /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 the two methods + `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] + 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 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("play: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/video_collection.py b/ovos_utils/skills/templates/video_collection.py index b1f9132..fb3c391 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"] = False + 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 += 10 + 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": 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/common_play.py b/ovos_utils/waiting_for_mycroft/common_play.py index 90fc28b..95b54e1 100644 --- a/ovos_utils/waiting_for_mycroft/common_play.py +++ b/ovos_utils/waiting_for_mycroft/common_play.py @@ -12,10 +12,9 @@ # 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 ovos_utils.waiting_for_mycroft.base_skill import MycroftSkill - +from ovos_utils.playback import CPSMatchType, CPSMatchLevel, CPSTrackStatus try: from mycroft.skills.common_play_skill import CommonPlaySkill as _CommonPlaySkill except ImportError: @@ -34,47 +33,6 @@ # https://github.com/MycroftAI/mycroft-core/pull/2660 -class CPSMatchLevel(IntEnum): - EXACT = 1 - MULTI_KEY = 2 - TITLE = 3 - ARTIST = 4 - CATEGORY = 5 - 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 4f5f227..63b94a1 100644 --- a/setup.py +++ b/setup.py @@ -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'],