Merge branch 'screen-flicker-fix' into 'master'

Handling screen display change corner cases

Closes #1212

See merge request openlp/openlp!509
This commit is contained in:
Raoul Snyman 2022-11-28 18:16:15 +00:00
commit 75020cdba4
7 changed files with 193 additions and 37 deletions

View File

@ -163,7 +163,7 @@ class Screen(object):
Callback function for when the screens geometry changes
"""
self.geometry = geometry
Registry().execute('config_screen_changed')
ConfigScreenChangedEmitter().emit()
class ScreenList(metaclass=Singleton):
@ -396,7 +396,7 @@ class ScreenList(metaclass=Singleton):
is_primary=self.application.primaryScreen() == changed_screen))
self.find_new_display_screen()
changed_screen.geometryChanged.connect(self.screens[-1].on_geometry_changed)
Registry().execute('config_screen_changed')
ConfigScreenChangedEmitter().emit()
def on_screen_removed(self, removed_screen):
"""
@ -417,7 +417,7 @@ class ScreenList(metaclass=Singleton):
self.screens.pop(removed_screen_number)
if removed_screen_is_display:
self.find_new_display_screen()
Registry().execute('config_screen_changed')
ConfigScreenChangedEmitter().emit()
def on_primary_screen_changed(self):
"""
@ -426,5 +426,21 @@ class ScreenList(metaclass=Singleton):
for screen in self.screens:
screen.is_primary = self.application.primaryScreen().geometry() == screen.geometry
self.find_new_display_screen()
ConfigScreenChangedEmitter().emit()
SCREEN_CHANGED_DEBOUNCE_TIMEOUT = 350
class ConfigScreenChangedEmitter(metaclass=Singleton):
def __init__(self):
self.timer = QtCore.QTimer(None)
self.timer.setInterval(SCREEN_CHANGED_DEBOUNCE_TIMEOUT)
self.timer.setSingleShot(True)
self.timer.timeout.connect(self.__do_emit_config_screen_changed)
def emit(self):
self.timer.start()
def __do_emit_config_screen_changed(self):
Registry().execute('config_screen_changed')

View File

@ -130,11 +130,13 @@ 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):
def __init__(self, parent=None, screen=None, can_show_startup_screen=True, start_hidden=False,
after_loaded_callback=None):
"""
Create the display window
"""
super(DisplayWindow, self).__init__(parent)
self.after_loaded_callback = after_loaded_callback
# 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'):
@ -183,7 +185,7 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin):
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'):
if not start_hidden and (len(ScreenList()) > 1 or self.settings.value('core/display on monitor')):
self.show()
def closeEvent(self, event):
@ -303,6 +305,8 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin):
self.set_scale(self.scale)
if self._can_show_startup_screen:
self.set_startup_screen()
if self.after_loaded_callback:
self.after_loaded_callback()
def run_javascript(self, script, is_sync=False):
"""

View File

@ -25,6 +25,7 @@ import shutil
from datetime import datetime, date
from pathlib import Path
from tempfile import gettempdir
from threading import Lock
from PyQt5 import QtCore, QtGui, QtWidgets
@ -526,6 +527,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow, LogMixin, RegistryPropert
# Starting up web services
self.http_server = HttpServer(self)
self.ws_server = WebSocketServer()
self.screen_updating_lock = Lock()
def _wait_for_threads(self):
"""
@ -1015,25 +1017,35 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow, LogMixin, RegistryPropert
"""
The screen has changed so we have to update components such as the renderer.
"""
self.application.set_busy_cursor()
self.renderer.resize(self.live_controller.screens.current.display_geometry.size())
self.preview_controller.screen_size_changed()
self.live_controller.setup_displays()
self.live_controller.screen_size_changed()
self.setFocus()
self.activateWindow()
self.application.set_normal_cursor()
# if a warning has been shown within the last 5 seconds, skip showing again to avoid spamming user,
# also do not show if the settings window is visible
if not self.settings_form.isVisible() and not self.screen_change_timestamp or \
self.screen_change_timestamp and (datetime.now() - self.screen_change_timestamp).seconds > 5:
self.screen_change_timestamp = datetime.now()
QtWidgets.QMessageBox.warning(self, translate('OpenLP.MainWindow', 'Screen setup has changed'),
translate('OpenLP.MainWindow',
'The screen setup has changed. '
'OpenLP will try to automatically select a display screen, but '
'you should consider updating the screen settings.'),
QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Ok))
try:
self.screen_updating_lock.acquire()
# if a warning has been shown within the last 5 seconds, skip showing again to avoid spamming user,
# also do not show if the settings window is visible
has_shown_messagebox_recently = self.screen_change_timestamp \
and (datetime.now() - self.screen_change_timestamp).seconds < 5
should_show_messagebox = self.settings_form.isHidden() and not has_shown_messagebox_recently
if should_show_messagebox:
QtWidgets.QMessageBox.warning(self, translate('OpenLP.MainWindow', 'Screen setup has changed'),
translate('OpenLP.MainWindow',
'The screen setup has changed. '
'OpenLP will try to automatically select a display screen, but '
'you should consider updating the screen settings.'),
QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Ok))
self.screen_change_timestamp = datetime.now()
self.application.set_busy_cursor()
self.renderer.resize(self.live_controller.screens.current.display_geometry.size())
self.preview_controller.screen_size_changed()
self.live_controller.setup_displays()
self.live_controller.screen_size_changed()
self.setFocus()
self.activateWindow()
self.application.set_normal_cursor()
# Forcing application to process events to trigger display update
# We need to wait a little of time as it would otherwise need a mouse move
# to process the screen change, for example
QtCore.QTimer.singleShot(150, lambda: self.application.process_events())
finally:
self.screen_updating_lock.release()
def closeEvent(self, event):
"""

View File

@ -181,12 +181,22 @@ class SlideController(QtWidgets.QWidget, LogMixin, RegistryProperties):
self.close_displays()
for screen in self.screens:
if screen.is_display:
display = DisplayWindow(self, screen)
will_start_hidden = self._current_hide_mode == HideMode.Screen
display = DisplayWindow(self, screen, start_hidden=will_start_hidden,
after_loaded_callback=self._display_after_loaded_callback)
self.displays.append(display)
self._reset_blank(False)
if self.display:
self.__add_actions_to_widget(self.display)
def _display_after_loaded_callback(self):
# As the display was reloaded, we'll need to process current item again
if self.service_item:
self._process_item(self.service_item, self.selected_row, is_reloading=True)
# Restoring last hide mode
if self._current_hide_mode:
self.display.hide_display(self._current_hide_mode)
def close_displays(self):
"""
Close all open displays
@ -929,12 +939,13 @@ class SlideController(QtWidgets.QWidget, LogMixin, RegistryProperties):
for display in self.displays:
display.set_theme(theme_data, service_item_type=service_item.service_item_type)
def _process_item(self, service_item, slide_no):
def _process_item(self, service_item, slide_no, is_reloading=False):
"""
Loads a ServiceItem into the system from ServiceManager. Display the slide number passed.
:param service_item: The current service item
:param slide_no: The slide number to select
:param is_reloading: If the controller is reloading the current item, due to a display update (for example).
"""
self.log_debug('_process_item start')
self.on_stop_loop()
@ -1044,7 +1055,7 @@ class SlideController(QtWidgets.QWidget, LogMixin, RegistryProperties):
self.application.process_events()
self.ignore_toolbar_resize_events = False
self.on_controller_size_changed()
if self.settings.value('core/auto unblank'):
if not is_reloading and self.settings.value('core/auto unblank'):
self.set_hide_mode(None)
self.log_debug('_process_item end')

View File

@ -29,6 +29,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
from PyQt5 import QtCore
from openlp.core.display.screens import Screen
# Mock QtWebEngineWidgets
sys.modules['PyQt5.QtWebEngineWidgets'] = MagicMock()
@ -123,6 +124,48 @@ def test_not_macos_toolwindow_attribute_set(mocked_is_macosx, mock_settings, dis
assert display_window.testAttribute(QtCore.Qt.WA_MacAlwaysShowToolWindow) is False
@patch.object(DisplayWindow, 'show')
def test_not_shown_if_start_hidden_is_set(mocked_show, display_window_env, mock_settings):
"""
Tests if DisplayWindow's .show() method is not called on constructor if constructed with start_hidden=True
"""
# GIVEN: A mocked DisplayWindow's show method, a fake screen and relevant settings
settings = {
'advanced/x11 bypass wm': False,
'core/display on monitor': True
}
mock_settings.value.side_effect = lambda key: settings[key]
screen = Screen(1, QtCore.QRect(0, 0, 800, 600), is_display=True)
# WHEN: A DisplayWindow is created with start_hidden=True
DisplayWindow(screen=screen, start_hidden=True)
# THEN: Window is not shown
mocked_show.assert_not_called()
@patch.object(DisplayWindow, 'show')
def test_shown_if_start_hidden_is_not_set(mocked_show, display_window_env, mock_settings):
"""
Tests if DisplayWindow's .show() method is called on constructor if constructed with start_hidden=False
"""
# GIVEN: A mocked DisplayWindow's show method, a fake screen and relevant settings
settings = {
'advanced/x11 bypass wm': False,
'core/display on monitor': True
}
mock_settings.value.side_effect = lambda key: settings[key]
screen = Screen(1, QtCore.QRect(0, 0, 800, 600), is_display=True)
# WHEN: A DisplayWindow is created with start_hidden=True
DisplayWindow(screen=screen, start_hidden=False)
# THEN: Window is shown
mocked_show.assert_called()
def test_set_scale_not_initialised(display_window_env, mock_settings):
"""
Test that the scale js is not run if the page is not initialised
@ -318,6 +361,27 @@ def test_after_loaded_hide_mouse_not_display(display_window_env, mock_settings):
'});')
def test_after_loaded_callback(display_window_env, mock_settings):
"""
Test if the __ is loaded on after_loaded() method correctly
"""
# GIVEN: An initialised display window and settings for item transitions and hide mouse returns true
mocked_after_loaded_callback = MagicMock()
display_window = DisplayWindow(after_loaded_callback=mocked_after_loaded_callback)
display_window.is_display = True
mock_settings.value.return_value = True
display_window._is_initialised = True
display_window.run_javascript = MagicMock()
display_window.set_scale = MagicMock()
display_window.set_startup_screen = MagicMock()
# WHEN: after_loaded is run
display_window.after_loaded()
# THEN: The after_loaded_callback should be called
mocked_after_loaded_callback.assert_called_once()
@patch.object(time, 'time')
def test_run_javascript_no_sync_no_wait(mock_time, display_window_env, mock_settings):
"""

View File

@ -771,9 +771,9 @@ def test_screen_changed_modal(mocked_warning, main_window):
@patch('openlp.core.ui.mainwindow.QtWidgets.QMessageBox.warning')
def test_screen_changed_modal_sets_timestamp_before_blocking_on_modal(mocked_warning, main_window):
def test_screen_changed_modal_sets_timestamp_after_blocking_on_modal(mocked_warning, main_window):
"""
Test that the screen changed modal latest shown timestamp is set before showing warning message, so
Test that the screen changed modal latest shown timestamp is set after showing warning message, so
that duplicate modals due to event spamming on 'config_screen_changed' in less than 5 seconds is mitigated.
"""
# GIVEN: a newly opened OpenLP instance, mocked screens, renderer and an special QMessageBox warning handler
@ -781,19 +781,13 @@ def test_screen_changed_modal_sets_timestamp_before_blocking_on_modal(mocked_war
main_window._preview_controller = MagicMock()
main_window._renderer = MagicMock()
def resets_timestamp(*args, **kwargs):
nonlocal main_window
main_window.screen_change_timestamp = None
mocked_warning.side_effect = resets_timestamp
# WHEN: we trigger a 'config_screen_changed' event
Registry().execute('config_screen_changed')
# THEN: main_window.screen_change_timestamp should be "None", indicating that timestamp is set before
# THEN: main_window.screen_change_timestamp should have a timestamp, indicating that timestamp is set after
# the blocking modal is shown.
mocked_warning.assert_called_once()
assert main_window.screen_change_timestamp is None
assert main_window.screen_change_timestamp is not None
@patch('openlp.core.ui.mainwindow.QtWidgets.QMessageBox.critical')

View File

@ -1134,6 +1134,61 @@ def test_process_item_song_no_vlc(mocked_execute, registry, state_media):
assert 2 == slide_controller.preview_display.load_verses.call_count, 'Execute should have been called 2 times'
@patch.object(Registry, 'execute')
def test_process_item_is_reloading_wont_change_display_hide_mode(mocked_execute, registry, state_media):
"""
Test if the display's hide mode is not changed when using is_reloading parameter
"""
# GIVEN: A mocked presentation service item, a mocked media service item, a mocked Registry.execute
# and a slide controller with many mocks.
# and the setting 'themes/item transitions' = True
mocked_media_item = MagicMock()
mocked_media_item.name = 'mocked_media_item'
mocked_media_item.get_transition_delay.return_value = 0
mocked_media_item.is_text.return_value = True
mocked_media_item.is_command.return_value = False
mocked_media_item.is_media.return_value = False
mocked_media_item.requires_media.return_value = False
mocked_media_item.is_image.return_value = False
mocked_media_item.from_service = False
mocked_media_item.get_frames.return_value = []
mocked_media_item.display_slides = [{'verse': 'Verse name'}]
mocked_settings = MagicMock()
mocked_settings.value.return_value = True
mocked_main_window = MagicMock()
Registry().register('main_window', mocked_main_window)
Registry().register('media_controller', MagicMock())
Registry().register('application', MagicMock())
Registry().register('settings', mocked_settings)
slide_controller = SlideController(None)
slide_controller.service_item = None
slide_controller.is_live = True
slide_controller._reset_blank = MagicMock()
slide_controller.preview_widget = MagicMock()
slide_controller.preview_display = MagicMock()
slide_controller.enable_tool_bar = MagicMock()
slide_controller.on_controller_size_changed = MagicMock()
slide_controller.on_media_start = MagicMock()
slide_controller.on_media_close = MagicMock()
slide_controller.slide_selected = MagicMock()
slide_controller.set_hide_mode = MagicMock()
slide_controller.new_song_menu = MagicMock()
slide_controller.on_stop_loop = MagicMock()
slide_controller.info_label = MagicMock()
slide_controller.song_menu = MagicMock()
slide_controller.displays = [MagicMock()]
slide_controller.toolbar = MagicMock()
slide_controller.split = 0
slide_controller.type_prefix = 'test'
slide_controller.screen_capture = 'old_capture'
# WHEN: _process_item is called with is_reloading=True
slide_controller._process_item(mocked_media_item, 0, is_reloading=True)
# THEN: set_hide_mode should not be called
slide_controller.set_hide_mode.assert_not_called()
def test_live_stolen_focus_shortcuts(settings):
"""
Test that all the needed shortcuts are available in scenarios where Live has stolen focus.