diff --git a/openlp/core/display/html/display.css b/openlp/core/display/html/display.css
index 5307e589a..053ddc30e 100644
--- a/openlp/core/display/html/display.css
+++ b/openlp/core/display/html/display.css
@@ -46,6 +46,27 @@ body.transition .reveal .footer {
transition: opacity 800ms ease-in-out !important;
}
+body {
+ /*
+ It's a fake transition, but the transitionend event will be
+ fired at the end of the transition time. For the user there will
+ be no animation (due to the curve
+ */
+ transition: opacity 75ms steps(1, start);
+}
+
+body.disable-transitions *,
+body.disable-transitions.transition,
+body.disable-transitions.transition .reveal .slides,
+body.disable-transitions.transition .reveal .footer {
+ transition: none !important;
+}
+
+body.is-desktop .pause-overlay {
+ /* Avoids a black flash while activating "Show Desktop" */
+ opacity: 0 !important;
+}
+
.reveal .slide-background {
opacity: 1;
visibility: visible;
diff --git a/openlp/core/display/html/display.js b/openlp/core/display/html/display.js
index c19a177bb..3f1996895 100644
--- a/openlp/core/display/html/display.js
+++ b/openlp/core/display/html/display.js
@@ -282,6 +282,8 @@ var Display = {
width: "100%",
height: "100%"
},
+ _lastRequestAnimationFrameHandle: null,
+
/**
* Start up reveal and do any other initialisation
* @param {object} options - The initialisation options:
@@ -376,7 +378,7 @@ var Display = {
Reveal.slide(0, currentSlide.v);
Reveal.sync();
Display._removeLastSection();
- Display._skipNextTransition = false;
+ Display._skipNextTransition = false;
}
},
/**
@@ -821,17 +823,37 @@ var Display = {
/**
* Blank the screen
*/
- toBlack: function () {
- var documentBody = $("body")[0];
- documentBody.style.opacity = 1;
- if (!Reveal.isPaused()) {
- Reveal.togglePause();
- }
+ toBlack: function (onFinishedEventName) {
+ /* Avoid race conditions where display goes to transparent and quickly goes to black */
+ Display._abortLastTransitionOperation();
+ /*
+ Reveal's black overlay should be shown before the transitions are
+ restored, to avoid screen flashes
+ */
+ Display._restorePauseBehavior();
+ Display._requestAnimationFrameExclusive(function() {
+ if (!Reveal.isPaused()) {
+ Reveal.togglePause();
+ }
+ Display._reenableGlobalTransitions(function() {
+ var documentBody = $("body")[0];
+ documentBody.style.opacity = 1;
+ if (onFinishedEventName) {
+ displayWatcher.dispatchEvent(onFinishedEventName, {});
+ }
+ });
+ });
},
/**
* Hide all but theme background
*/
- toTheme: function () {
+ toTheme: function (onFinishedEventName) {
+ Display._abortLastTransitionOperation();
+ /*
+ Reveal's black overlay should be shown before the transitions are
+ restored, to avoid screen flashes
+ */
+ Display._restorePauseBehavior();
var documentBody = $("body")[0];
documentBody.style.opacity = 1;
Display._slidesContainer.style.opacity = 0;
@@ -839,31 +861,140 @@ var Display = {
if (Reveal.isPaused()) {
Reveal.togglePause();
}
+ Display._reenableGlobalTransitions(function() {
+ if (onFinishedEventName) {
+ displayWatcher.dispatchEvent(onFinishedEventName, {});
+ }
+ });
},
/**
* Hide everything (CAUTION: Causes a invisible mouse barrier)
*/
- toTransparent: function () {
- Display._slidesContainer.style.opacity = 0;
- Display._footerContainer.style.opacity = 0;
+ toTransparent: function (onFinishedEventName) {
+ Display._abortLastTransitionOperation();
var documentBody = $("body")[0];
documentBody.style.opacity = 0;
if (!Reveal.isPaused()) {
+ /*
+ Removing previously the overlay if it's not paused, to avoid a
+ content flash while going from black screen to transparent
+ */
+ document.body.classList.add('is-desktop');
Reveal.togglePause();
}
+ /*
+ Waiting for body transition to happen, now it would be safe to
+ hide the Webview (as other transitions were suppressed)
+ */
+ Display._abortLastTransitionOperation();
+ Display._addTransitionEndEventToBody(transitionEndEvent);
+ function transitionEndEvent(e) {
+ // Targeting only body
+ if (e.target != documentBody) {
+ return;
+ }
+ /*
+ Disabling all transitions (except body) to allow the Webview to attain the
+ transparent state before it gets hidden by Qt.
+ */
+ document.body.classList.add('disable-transitions');
+ document.body.classList.add('is-desktop');
+ Display._slidesContainer.style.opacity = 0;
+ Display._footerContainer.style.opacity = 0;
+ /*
+ Repainting before hiding the Webview to avoid flashes when
+ showing it again.
+ */
+ displayWatcher.pleaseRepaint();
+ /* Waiting for repaint to happen before saying that it's done. */
+ Display._requestAnimationFrameExclusive(function() {
+ /* We're transparent now, aborting any transition event between */
+ Display._abortLastTransitionOperation();
+ if (onFinishedEventName) {
+ displayWatcher.dispatchEvent(onFinishedEventName, {});
+ }
+ });
+ }
},
/**
* Show the screen
*/
- show: function () {
+ show: function (onFinishedEventName) {
var documentBody = $("body")[0];
- documentBody.style.opacity = 1;
+ /*
+ Removing transitionend event, avoids the content being hidden if the user
+ tries to show content again before toTransparent() transitionend event
+ happens
+ */
+ Display._abortLastTransitionOperation();
+
Display._slidesContainer.style.opacity = 1;
Display._footerContainer.style.opacity = 1;
if (Reveal.isPaused()) {
Reveal.togglePause();
}
+ Display._restorePauseBehavior();
+ Display._reenableGlobalTransitions(function() {
+ documentBody.style.opacity = 1;
+ if (onFinishedEventName) {
+ displayWatcher.dispatchEvent(onFinishedEventName, {});
+ }
+ });
},
+
+ _reenableGlobalTransitions: function(afterCallback) {
+ Display._requestAnimationFrameExclusive(function() {
+ /*
+ Waiting for the previous opacity + unpause operations to complete
+ to restore the transitions behavior
+ */
+ document.body.classList.remove('disable-transitions');
+ if (typeof afterCallback === 'function') {
+ afterCallback();
+ }
+ });
+ },
+
+ /**
+ * Shows again the Reveal's black pause overlay that was
+ * hidden before Webview was hidden
+ */
+ _restorePauseBehavior: function() {
+ document.body.classList.remove('is-desktop');
+ },
+
+ /**
+ * Cancels previous requested animationFrame.
+ * Last animationFrame should be aborted to avoid race condition bugs when
+ * the user changes the view modes too quickly, for example.
+ */
+ _requestAnimationFrameExclusive: function(callback) {
+ cancelAnimationFrame(Display._lastRequestAnimationFrameHandle);
+ Display._lastRequestAnimationFrameHandle = requestAnimationFrame(callback);
+ },
+
+ /**
+ * Aborts last body's transitionend and requestAnimationFrame's events, to avoid
+ * race condition bugs.
+ */
+ _abortLastTransitionOperation: function() {
+ Display._removeTransitionEndEventToBody();
+ cancelAnimationFrame(Display._lastRequestAnimationFrameHandle);
+ },
+
+ /**
+ * Intercepts the addEventListener call and stores it, so that it acts
+ * like the ontransitionend GlobalEventHandler.
+ */
+ _addTransitionEndEventToBody: function(listener) {
+ Display._lastTransitionEndBodyEvent = listener;
+ document.body.addEventListener('transitionend', listener);
+ },
+
+ _removeTransitionEndEventToBody: function() {
+ document.body.removeEventListener('transitionend', Display._lastTransitionEndBodyEvent);
+ },
+
/**
* Figure out how many lines can fit on a slide given the font size
* @param fontSize The font size in pts
@@ -1129,11 +1260,11 @@ var Display = {
* and we don't want any flashbacks to the current slide contents
*/
finishWithCurrentItem: function () {
- Display.setTextSlide('');
- var documentBody = $("body")[0];
+ Display.setTextSlide('');
+ var documentBody = $("body")[0];
documentBody.style.opacity = 1;
- Display._skipNextTransition = true;
- displayWatcher.pleaseRepaint();
+ Display._skipNextTransition = true;
+ displayWatcher.pleaseRepaint();
},
/**
* Return the video types supported by the video tag
@@ -1185,7 +1316,7 @@ var Display = {
*/
setFooterSlideNumbers: function (slide) {
let value = ['', '', ''];
- // Reveal does call this function passing undefined
+ // Reveal does call this function passing undefined
if (typeof slide === 'undefined') {
return value;
}
diff --git a/openlp/core/display/window.py b/openlp/core/display/window.py
index 3de5fff84..0b9e058e6 100644
--- a/openlp/core/display/window.py
+++ b/openlp/core/display/window.py
@@ -42,6 +42,7 @@ from openlp.core.ui import HideMode
FONT_FOUNDRY = re.compile(r'(.*?) \[(.*?)\]')
+TRANSITION_END_EVENT_NAME = 'transparent_transition_end'
log = logging.getLogger(__name__)
@@ -54,6 +55,9 @@ class DisplayWatcher(QtCore.QObject):
def __init__(self, parent):
super().__init__()
self._display_window = parent
+ self._transient_dispatch_events = {}
+ self._permanent_dispatch_events = {}
+ self._event_counter = 0
@QtCore.pyqtSlot(bool)
def setInitialised(self, is_initialised):
@@ -70,6 +74,57 @@ class DisplayWatcher(QtCore.QObject):
"""
self._display_window.webview.update()
+ @QtCore.pyqtSlot(str, 'QJsonObject')
+ def dispatchEvent(self, event_name, event_data):
+ """
+ Called from the js in the webengine view for event dispatches
+ """
+ transient_dispatch_events = self._transient_dispatch_events
+ permanent_dispatch_events = self._permanent_dispatch_events
+ if event_name in transient_dispatch_events:
+ event = transient_dispatch_events[event_name]
+ del transient_dispatch_events[event_name]
+ event(event_data)
+ if event_name in permanent_dispatch_events:
+ permanent_dispatch_events[event_name](event_data)
+
+ def register_event_listener(self, event_name, callback, transient=True):
+ """
+ Register an event listener from webengine view
+ :param event_name: Event name
+ :param callback: Callback listener when event happens
+ :param transient: If the event listener should be unregistered after being run
+ """
+ if transient:
+ events = self._transient_dispatch_events
+ else:
+ events = self._permanent_dispatch_events
+
+ events[event_name] = callback
+
+ def unregister_event_listener(self, event_name, transient=True):
+ """
+ Unregisters an event listener from webengine view
+ :param event_name: Event name
+ :param transient: If the event listener was registered as transient
+ """
+ if transient:
+ events = self._transient_dispatch_events
+ else:
+ events = self._permanent_dispatch_events
+
+ if event_name in events:
+ del events[event_name]
+
+ def get_unique_event_name(self):
+ """
+ Generates an unique event name
+ :returns: Unique event name
+ """
+ event_count = self._event_counter
+ self._event_counter += 1
+ return 'event_' + str(event_count)
+
class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin):
"""
@@ -431,9 +486,11 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin):
# 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();')
+ # Aborting setVisible(False) call in case the display modes are changed quickly
+ self.display_watcher.unregister_event_listener(TRANSITION_END_EVENT_NAME)
if self.isHidden():
self.setVisible(True)
+ self.run_javascript('Display.show();')
self.hide_mode = None
def hide_display(self, mode=HideMode.Screen):
@@ -447,19 +504,24 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin):
# Only make visible on single monitor setup if setting enabled.
if len(ScreenList()) == 1 and not self.settings.value('core/display on monitor'):
return
+ # Aborting setVisible(False) call in case the display modes are changed quickly
+ self.display_watcher.unregister_event_listener(TRANSITION_END_EVENT_NAME)
# Update display to the selected mode
+ if mode != HideMode.Screen:
+ if self.isHidden():
+ self.setVisible(True)
if mode == HideMode.Screen:
if self.settings.value('advanced/disable transparent display'):
- self.setVisible(False)
+ # Hide window only after all webview CSS ransitions are done
+ self.display_watcher.register_event_listener(TRANSITION_END_EVENT_NAME,
+ lambda _: self.setVisible(False))
+ self.run_javascript("Display.toTransparent('{}');".format(TRANSITION_END_EVENT_NAME))
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):
diff --git a/tests/js/test_display.js b/tests/js/test_display.js
index c6c73f4bc..88e3f93af 100644
--- a/tests/js/test_display.js
+++ b/tests/js/test_display.js
@@ -246,7 +246,92 @@ describe("Transitions", function () {
expect(Display._slidesContainer.children[0].children[0].getAttribute("data-transition")).toEqual("convex-vertical-reverse");
expect(Display._slidesContainer.children[0].children[0].getAttribute("data-transition-speed")).toEqual("slow");
});
+});
+
+describe("Screen Visibility", function () {
+ var TRANSITION_TIMEOUT = 2000;
+
+ beforeEach(function() {
+ window.displayWatcher = jasmine.createSpyObj('DisplayWatcher', ['dispatchEvent', 'setInitialised', 'pleaseRepaint']);
+ document.body.innerHTML = "";
+ var revealDiv = _createDiv({"class": "reveal"});
+ var slidesDiv = _createDiv({"class": "slides"});
+ var footerDiv = _createDiv({"class": "footer"});
+ slidesDiv.innerHTML = "";
+ revealDiv.append(slidesDiv);
+ revealDiv.append(footerDiv);
+ document.body.style.transition = "opacity 75ms ease-in-out";
+ Display.init({isDisplay: true, doItemTransition: false});
+ });
+ afterEach(function() {
+ // Reset theme
+ Display._theme = null;
+ });
+
+ it("should trigger dispatchEvent when toTransparent(eventName) is called with an event parameter", function (done) {
+ var testEventName = 'event_32';
+ displayWatcher.dispatchEvent = function(eventName) {
+ if (eventName == testEventName) {
+ done();
+ }
+ };
+
+ Display.toTransparent(testEventName);
+
+ setTimeout(function() {
+ fail('dispatchEvent not called');
+ done();
+ }, TRANSITION_TIMEOUT);
+ });
+
+ it("should trigger dispatchEvent when toBlack(eventName) is called with an event parameter", function (done) {
+ var testEventName = 'event_33';
+ displayWatcher.dispatchEvent = function(eventName) {
+ if (eventName == testEventName) {
+ done();
+ }
+ };
+
+ Display.toBlack(testEventName);
+
+ setTimeout(function() {
+ fail('dispatchEvent not called');
+ done();
+ }, TRANSITION_TIMEOUT);
+ });
+
+ it("should trigger dispatchEvent when toTheme(eventName) is called with an event parameter", function (done) {
+ var testEventName = 'event_34';
+ displayWatcher.dispatchEvent = function(eventName) {
+ if (eventName == testEventName) {
+ done();
+ }
+ };
+
+ Display.toTheme(testEventName);
+
+ setTimeout(function() {
+ fail('dispatchEvent not called');
+ done();
+ }, TRANSITION_TIMEOUT);
+ });
+
+ it("should trigger dispatchEvent when show(eventName) is called with an event parameter", function (done) {
+ var testEventName = 'event_35';
+ displayWatcher.dispatchEvent = function(eventName) {
+ if (eventName == testEventName) {
+ done();
+ }
+ };
+
+ Display.show(testEventName);
+
+ setTimeout(function() {
+ fail('dispatchEvent not called');
+ done();
+ }, TRANSITION_TIMEOUT);
+ });
});
describe("Display.alert", function () {
diff --git a/tests/openlp_core/display/test_window.py b/tests/openlp_core/display/test_window.py
index 3f504238f..4154fea5d 100644
--- a/tests/openlp_core/display/test_window.py
+++ b/tests/openlp_core/display/test_window.py
@@ -33,7 +33,7 @@ from PyQt5 import QtCore
# Mock QtWebEngineWidgets
sys.modules['PyQt5.QtWebEngineWidgets'] = MagicMock()
-from openlp.core.display.window import DisplayWindow, DisplayWatcher
+from openlp.core.display.window import TRANSITION_END_EVENT_NAME, DisplayWindow, DisplayWatcher
from openlp.core.common import is_win
from openlp.core.common.enum import ServiceItemType
from openlp.core.lib.theme import Theme
@@ -602,20 +602,150 @@ def test_hide_display_to_transparent(display_window_env, mock_settings):
assert display_window.setVisible.call_count == 0
+def test_display_watcher_dispatches_registered_event(display_window_env, mock_settings):
+ """
+ Test that the display watcher dispatches events to the registered listeners
+ """
+ # GIVEN: Display window and a dummy event
+ event_name = 'dummy_event'
+ event_listener = MagicMock()
+ display_window = DisplayWindow()
+ display_window.display_watcher.register_event_listener(event_name, event_listener)
+
+ # WHEN: Event is dispatched
+ display_window.display_watcher.dispatchEvent(event_name, {})
+
+ # THEN: Events should be called
+ event_listener.assert_called_once()
+
+
+def test_display_watcher_dispatches_permanent_registered_event(display_window_env, mock_settings):
+ """
+ Test that the display watcher dispatches events to the permanent registered listeners
+ """
+ # GIVEN: Display window and a dummy event
+ event_name = 'dummy_event'
+ event_listener = MagicMock()
+ display_window = DisplayWindow()
+ display_window.display_watcher.register_event_listener(event_name, event_listener, True)
+
+ # WHEN: Event is dispatched
+ display_window.display_watcher.dispatchEvent(event_name, {})
+
+ # THEN: Events should be called
+ event_listener.assert_called_once()
+
+
+def test_display_watcher_dispatches_transient_and_permanent_registered_event(display_window_env, mock_settings):
+ """
+ Test that the display watcher dispatches events to both transient and permanent registered listeners
+ """
+ # GIVEN: Display window and a dummy event
+ event_name = 'dummy_event'
+ event_listener = MagicMock()
+ event_listener_permanent = MagicMock()
+ display_window = DisplayWindow()
+ display_window.display_watcher.register_event_listener(event_name, event_listener, True)
+ display_window.display_watcher.register_event_listener(event_name, event_listener_permanent, False)
+
+ # WHEN: Event is dispatched
+ display_window.display_watcher.dispatchEvent(event_name, {})
+
+ # THEN: Events should be called
+ event_listener.assert_called_once()
+ event_listener_permanent.assert_called_once()
+
+
+def test_display_watcher_unregisters_registered_event(display_window_env, mock_settings):
+ """
+ Test that the display watcher unregisters registered listeners
+ """
+ # GIVEN: Display window and a dummy event that is unregistered later
+ event_name = 'dummy_event'
+ event_listener = MagicMock()
+ display_window = DisplayWindow()
+ display_window.display_watcher.register_event_listener(event_name, event_listener)
+ display_window.display_watcher.unregister_event_listener(event_name)
+
+ # WHEN: Event is dispatched
+ display_window.display_watcher.dispatchEvent(event_name, {})
+
+ # THEN: Events should not be called
+ event_listener.assert_not_called()
+
+
+def test_display_watcher_unregisters_registered_permanent_event(display_window_env, mock_settings):
+ """
+ Test that the display watcher unregisters registered permanent listeners
+ """
+ # GIVEN: Display window and a dummy event that is unregistered later
+ event_name = 'dummy_event'
+ event_listener = MagicMock()
+ display_window = DisplayWindow()
+ display_window.display_watcher.register_event_listener(event_name, event_listener, True)
+ display_window.display_watcher.unregister_event_listener(event_name)
+
+ # WHEN: Event is dispatched
+ display_window.display_watcher.dispatchEvent(event_name, {})
+
+ # THEN: Events should not be called
+ event_listener.assert_not_called()
+
+
+def test_display_watcher_unregisters_registered_permanent_and_transient_event(display_window_env, mock_settings):
+ """
+ Test that the display watcher unregisters registered listeners, both permanent and transient
+ """
+ # GIVEN: Display window and a dummy event that is unregistered later
+ event_name = 'dummy_event'
+ event_listener = MagicMock()
+ event_listener_permanent = MagicMock()
+ display_window = DisplayWindow()
+ display_window.display_watcher.register_event_listener(event_name, event_listener)
+ display_window.display_watcher.register_event_listener(event_name, event_listener_permanent, False)
+ display_window.display_watcher.unregister_event_listener(event_name)
+ display_window.display_watcher.unregister_event_listener(event_name, False)
+
+ # WHEN: Event is dispatched
+ display_window.display_watcher.dispatchEvent(event_name, {})
+
+ # THEN: Events should not be called
+ event_listener.assert_not_called()
+ event_listener_permanent.assert_not_called()
+
+
def test_hide_transparent_to_screen(display_window_env, mock_settings):
"""
Test that when going transparent, and the disable transparent setting is enabled,
the screen mode should be used.
"""
- # GIVEN: Display window and setting advanced/disable transparent display = True
+ # GIVEN: Display window, setting advanced/disable transparent display = True and mocked run_javascript
display_window = DisplayWindow()
display_window.setVisible = MagicMock()
+ has_ran_event = False
+
+ def set_has_ran_event(_):
+ nonlocal has_ran_event
+ has_ran_event = True
+
+ def on_dispatch_event(_):
+ display_window.display_watcher.register_event_listener(TRANSITION_END_EVENT_NAME, set_has_ran_event, False)
+ display_window.display_watcher.dispatchEvent(TRANSITION_END_EVENT_NAME, {})
+
+ display_window.run_javascript = MagicMock(side_effect=on_dispatch_event)
mock_settings.value.return_value = True
# WHEN: Hide display is run with HideMode.Screen
display_window.hide_display(HideMode.Screen)
# THEN: Should run setVisible(False)
+ elapsed_time = 0
+ while not has_ran_event:
+ time.sleep(0.05)
+ elapsed_time += 0.05
+ if elapsed_time > 1:
+ break
+ assert has_ran_event is True
display_window.setVisible.assert_called_once_with(False)