# -*- coding: utf-8 -*- ########################################################################## # OpenLP - Open Source Lyrics Projection # # ---------------------------------------------------------------------- # # Copyright (c) 2008-2022 OpenLP Developers # # ---------------------------------------------------------------------- # # This program is free software: you can redistribute it and/or modify # # it under the terms of the GNU General Public License as published by # # the Free Software Foundation, either version 3 of the License, or # # (at your option) any later version. # # # # This program is distributed in the hope that it will be useful, # # but WITHOUT ANY WARRANTY; without even the implied warranty of # # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # # GNU General Public License for more details. # # # # You should have received a copy of the GNU General Public License # # along with this program. If not, see . # ########################################################################## """ The :mod:`~openlp.core.display.window` module contains the display window """ import json import logging import os import copy import re from PyQt5 import QtCore, QtWebChannel, QtWidgets from openlp.core.common import is_win from openlp.core.common.applocation import AppLocation from openlp.core.common.enum import ServiceItemType from openlp.core.common.i18n import translate from openlp.core.common.mixins import LogMixin, RegistryProperties from openlp.core.common.path import path_to_str from openlp.core.common.registry import Registry from openlp.core.common.utils import wait_for from openlp.core.display.screens import ScreenList from openlp.core.ui import HideMode FONT_FOUNDRY = re.compile(r'(.*?) \[(.*?)\]') log = logging.getLogger(__name__) class DisplayWatcher(QtCore.QObject): """ This facilitates communication from the Display object in the browser back to the Python """ initialised = QtCore.pyqtSignal(bool) def __init__(self, parent): super().__init__() self._display_window = parent @QtCore.pyqtSlot(bool) def setInitialised(self, is_initialised): """ This method is called from the JS in the browser to set the _is_initialised attribute """ log.info('Display is initialised: {init}'.format(init=is_initialised)) self.initialised.emit(is_initialised) @QtCore.pyqtSlot() def pleaseRepaint(self): """ Called from the js in the webengine view when it's requesting a repaint by Qt """ self._display_window.webview.update() class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin): """ This is a window to show the output """ def __init__(self, parent=None, screen=None, can_show_startup_screen=True): """ Create the display window """ super(DisplayWindow, self).__init__(parent) # Gather all flags for the display window flags = QtCore.Qt.FramelessWindowHint | QtCore.Qt.Tool | QtCore.Qt.WindowStaysOnTopHint if self.settings.value('advanced/x11 bypass wm'): flags |= QtCore.Qt.X11BypassWindowManagerHint # Need to import this inline to get around a QtWebEngine issue from openlp.core.display.webengine import WebEngineView self._is_initialised = False self._can_show_startup_screen = can_show_startup_screen self._fbo = None self.setWindowTitle(translate('OpenLP.DisplayWindow', 'Display Window')) self.setWindowFlags(flags) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.setAutoFillBackground(True) self.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.layout = QtWidgets.QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.webview = WebEngineView(self) self.webview.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.webview.page().setBackgroundColor(QtCore.Qt.transparent) self.webview.display_clicked = self.disable_display self.layout.addWidget(self.webview) self.webview.loadFinished.connect(self.after_loaded) display_base_path = AppLocation.get_directory(AppLocation.AppDir) / 'core' / 'display' / 'html' self.display_path = display_base_path / 'display.html' self.checkerboard_path = display_base_path / 'checkerboard.png' self.openlp_splash_screen_path = display_base_path / 'openlp-splash-screen.png' self.channel = QtWebChannel.QWebChannel(self) self.display_watcher = DisplayWatcher(self) self.channel.registerObject('displayWatcher', self.display_watcher) self.webview.page().setWebChannel(self.channel) self.display_watcher.initialised.connect(self.on_initialised) self.set_url(QtCore.QUrl.fromLocalFile(path_to_str(self.display_path))) self.is_display = False self.scale = 1 self.hide_mode = None self.__script_done = True self.__script_result = None if screen and screen.is_display: # use log_debug to set up function wrapping before registering functions self.log_debug('registering live display show/hide functions') Registry().register_function('live_display_hide', self.hide_display) Registry().register_function('live_display_show', self.show_display) self.update_from_screen(screen) self.is_display = True # Only make visible on single monitor setup if setting enabled. if len(ScreenList()) > 1 or self.settings.value('core/display on monitor'): self.show() def _fix_font_name(self, font_name): """ Do some font machinations to see if we can fix the font name """ # Some fonts on Windows that end in "Bold" are made into a base font that is bold if is_win() and font_name.endswith(' Bold'): font_name = font_name.split(' Bold')[0] # Some fonts may have the Foundry name in their name. Remove the foundry name match = FONT_FOUNDRY.match(font_name) if match: font_name = match.group(1) return font_name def deregister_display(self): """ De-register this displays callbacks in the registry to be able to remove it """ if self.is_display: Registry().remove_function('live_display_hide', self.hide_display) Registry().remove_function('live_display_show', self.show_display) @property def is_initialised(self): return self._is_initialised def on_initialised(self, is_initialised): """ Update the initialised status """ self._is_initialised = is_initialised def update_from_screen(self, screen): """ Update the number and the geometry from the screen. :param Screen screen: A `~openlp.core.display.screens.Screen` instance """ self.setGeometry(screen.display_geometry) self.screen_number = screen.number def set_background_image(self, image_path): image_uri = image_path.as_uri() self.run_javascript('Display.setBackgroundImage("{image}");'.format(image=image_uri)) def set_single_image(self, bg_color, image_path): """ :param str bg_color: Background color :param Path image_path: Path to the image """ image_uri = image_path.as_uri() self.run_javascript('Display.setFullscreenImage("{bg_color}", "{image}");'.format(bg_color=bg_color, image=image_uri)) def set_single_image_data(self, bg_color, image_data): self.run_javascript('Display.setFullscreenImageFromData("{bg_color}", ' '"{image_data}");'.format(bg_color=bg_color, image_data=image_data)) def set_startup_screen(self): bg_color = self.settings.value('core/logo background color') image = self.settings.value('core/logo file') if path_to_str(image).startswith(':'): image = self.openlp_splash_screen_path try: image_uri = image.as_uri() except Exception: image_uri = '' # if set to hide logo on startup, do not send the logo if self.settings.value('core/logo hide on startup'): image_uri = '' self.run_javascript('Display.setStartupSplashScreen("{bg_color}", "{image}");'.format(bg_color=bg_color, image=image_uri)) def set_url(self, url): """ Set the URL of the webview :param QtCore.QUrl | str url: The URL to set """ if not isinstance(url, QtCore.QUrl): url = QtCore.QUrl(url) self.webview.setUrl(url) def set_html(self, html): """ Set the html """ self.webview.setHtml(html) def after_loaded(self): """ Add stuff after page initialisation """ js_is_display = str(self.is_display).lower() item_transitions = str(self.settings.value('themes/item transitions')).lower() hide_mouse = str(self.settings.value('advanced/hide mouse') and self.is_display).lower() slide_numbers_in_footer = str(self.settings.value('advanced/slide numbers in footer')).lower() self.run_javascript('Display.init({{' 'isDisplay: {is_display},' 'doItemTransitions: {do_item_transitions},' 'slideNumbersInFooter: {slide_numbers_in_footer},' 'hideMouse: {hide_mouse}' '}});' .format(is_display=js_is_display, do_item_transitions=item_transitions, slide_numbers_in_footer=slide_numbers_in_footer, hide_mouse=hide_mouse)) wait_for(lambda: self._is_initialised) if self.scale != 1: self.set_scale(self.scale) if self._can_show_startup_screen: self.set_startup_screen() def run_javascript(self, script, is_sync=False): """ Run some Javascript in the WebView :param script: The script to run, a string :param is_sync: Run the script synchronously. Defaults to False """ log.debug((script[:80] + '..') if len(script) > 80 else script) # Wait for previous scripts to finish wait_for(lambda: self.__script_done) if is_sync: self.__script_done = False self.__script_result = None def handle_result(result): """ Handle the result from the asynchronous call """ self.__script_done = True self.__script_result = result self.webview.page().runJavaScript(script, handle_result) # Wait for script to finish if not wait_for(lambda: self.__script_done): self.__script_done = True return self.__script_result else: self.webview.page().runJavaScript(script) self.raise_() def go_to_slide(self, verse): """ Go to a particular slide. :param str verse: The verse to go to, e.g. "V1" for songs, or just "0" for other types """ self.run_javascript('Display.goToSlide("{verse}");'.format(verse=verse)) def load_verses(self, verses, is_sync=False): """ Set verses in the display """ json_verses = json.dumps(verses) self.run_javascript('Display.setTextSlides({verses});'.format(verses=json_verses), is_sync=is_sync) def load_images(self, images): """ Set images in the display """ imagesr = copy.deepcopy(images) for image in imagesr: image['path'] = image['path'].as_uri() # Not all images has a dedicated thumbnail (such as images loaded from old or local servicefiles), # in that case reuse the image if image.get('thumbnail', None): image['thumbnail'] = image['thumbnail'].as_uri() else: image['thumbnail'] = image['path'] json_images = json.dumps(imagesr) self.run_javascript('Display.setImageSlides({images});'.format(images=json_images)) def load_video(self, video): """ Load video in the display """ video = copy.deepcopy(video) video['path'] = video['path'].as_uri() json_video = json.dumps(video) self.run_javascript('Display.setVideo({video});'.format(video=json_video)) def play_video(self): """ Play the currently loaded video """ self.run_javascript('Display.playVideo();') def pause_video(self): """ Pause the currently playing video """ self.run_javascript('Display.pauseVideo();') def stop_video(self): """ Stop the currently playing video """ self.run_javascript('Display.stopVideo();') def set_video_playback_rate(self, rate): """ Set the playback rate of the current video. The rate can be any valid float, with 0.0 being stopped, 1.0 being normal speed, over 1.0 is faster, under 1.0 is slower, and negative is backwards. :param rate: A float indicating the playback rate. """ self.run_javascript('Display.setPlaybackRate({rate});'.format(rate=rate)) def set_video_volume(self, level): """ Set the volume of the current video. The volume should be an int from 0 to 100, where 0 is no sound and 100 is maximum volume. Any values outside this range will raise a ``ValueError``. :param level: A number between 0 and 100 """ if level < 0 or level > 100: raise ValueError('Volume should be from 0 to 100, was "{}"'.format(level)) self.run_javascript('Display.setVideoVolume({level});'.format(level=level)) def toggle_video_mute(self): """ Toggle the mute of the current video """ self.run_javascript('Display.toggleVideoMute();') def save_screenshot(self, fname=None): """ Save a screenshot, either returning it or saving it to file """ pixmap = self.grab() if fname: ext = os.path.splitext(fname)[-1][1:] pixmap.save(fname, ext) else: return pixmap def set_theme(self, theme, is_sync=False, service_item_type=False): """ Set the theme of the display """ theme_copy = copy.deepcopy(theme) if self.is_display: if service_item_type == ServiceItemType.Text: if theme.background_type == 'video' or theme.background_type == 'stream': theme_copy.background_type = 'transparent' else: # If preview Display with media background we just show the background color, no media if theme.background_type == 'stream' or theme.background_type == 'video': theme_copy.background_type = 'solid' theme_copy.background_start_color = theme.background_border_color theme_copy.background_end_color = theme.background_border_color theme_copy.background_main_color = theme.background_border_color theme_copy.background_footer_color = theme.background_border_color theme_copy.background_color = theme.background_border_color # If preview Display for media so we need to display black box. elif service_item_type == ServiceItemType.Command or theme.background_type == 'live': theme_copy.background_type = 'solid' theme_copy.background_start_color = '#590909' theme_copy.background_end_color = '#590909' theme_copy.background_main_color = '#090909' theme_copy.background_footer_color = '#090909' # Do some font-checking, see https://gitlab.com/openlp/openlp/-/issues/39 theme_copy.font_main_name = self._fix_font_name(theme.font_main_name) theme_copy.font_footer_name = self._fix_font_name(theme.font_footer_name) exported_theme = theme_copy.export_theme(is_js=True) self.run_javascript('Display.setTheme({theme});'.format(theme=exported_theme), is_sync=is_sync) def reload_theme(self): """ Applies the set theme DO NOT use this when changing slides. Only use this if you need to force an update to the current visible slides. """ self.run_javascript('Display.resetTheme();') def get_video_types(self): """ Get the types of videos playable by the embedded media player """ return self.run_javascript('Display.getVideoTypes();', is_sync=True) def show_display(self): """ Show the display """ if self.is_display: # Only make visible on single monitor setup if setting enabled. if len(ScreenList()) == 1 and not self.settings.value('core/display on monitor'): return self.run_javascript('Display.show();') if self.isHidden(): self.setVisible(True) self.hide_mode = None def hide_display(self, mode=HideMode.Screen): """ Hide the display by making all layers transparent Store the images so they can be replaced when required :param mode: How the screen is to be hidden """ log.debug('hide_display mode = {mode:d}'.format(mode=mode)) if self.is_display: # Only make visible on single monitor setup if setting enabled. if len(ScreenList()) == 1 and not self.settings.value('core/display on monitor'): return # Update display to the selected mode if mode == HideMode.Screen: if self.settings.value('advanced/disable transparent display'): self.setVisible(False) else: self.run_javascript('Display.toTransparent();') elif mode == HideMode.Blank: self.run_javascript('Display.toBlack();') elif mode == HideMode.Theme: self.run_javascript('Display.toTheme();') if mode != HideMode.Screen: if self.isHidden(): self.setVisible(True) self.hide_mode = mode def disable_display(self): """ Removes the display if showing desktop This allows users to click though the screen even if the screen is only transparent """ if self.is_display and self.hide_mode == HideMode.Screen: self.setVisible(False) def finish_with_current_item(self): """ This is called whenever the song/image display is followed by eg a presentation or video which has its own display. This function ensures that the current item won't flash momentarily when the webengineview is displayed for a subsequent song or image. """ self.run_javascript('Display.finishWithCurrentItem();', True) self.webview.update() def set_scale(self, scale): """ Set the HTML scale """ self.scale = scale # Only scale if initialised (scale run again once initialised) if self._is_initialised: self.run_javascript('Display.setScale({scale});'.format(scale=scale * 100)) def alert(self, text, settings): """ Set an alert """ self.run_javascript('Display.alert("{text}", {settings});'.format(text=text, settings=settings))