← Back to team overview

openlp-core team mailing list archive

[Merge] lp:~raoul-snyman/openlp/animated-alerts into lp:openlp

 

Raoul Snyman has proposed merging lp:~raoul-snyman/openlp/animated-alerts into lp:openlp.

Commit message:
Based on ic90's code, this just simplifies some of the code, and streamlines how things work, plus a couple of CSS fixes.

Requested reviews:
  OpenLP Core (openlp-core)

For more details, see:
https://code.launchpad.net/~raoul-snyman/openlp/animated-alerts/+merge/372733

Based on ic90's code, this just simplifies some of the code, and streamlines how things work, plus a couple of CSS fixes.
-- 
Your team OpenLP Core is requested to review the proposed merge of lp:~raoul-snyman/openlp/animated-alerts into lp:openlp.
=== added file 'openlp/core/display/html/display.css'
--- openlp/core/display/html/display.css	1970-01-01 00:00:00 +0000
+++ openlp/core/display/html/display.css	2019-09-12 22:43:33 +0000
@@ -0,0 +1,90 @@
+@keyframes alert-scrolling-text {
+  0% {
+    opacity: 1;
+    transform: translateX(100%);
+  }
+  99% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+    transform: translateX(-101%);
+  }
+}
+
+body {
+  background: transparent !important;
+  color: rgb(255, 255, 255) !important;
+}
+
+sup {
+  vertical-align: super !important;
+  font-size: smaller !important;
+}
+
+.reveal .slides > section,
+.reveal .slides > section > section {
+  padding: 0;
+}
+
+.reveal > .backgrounds > .present {
+  visibility: hidden !important;
+}
+
+#global-background {
+  display: block;
+  visibility: visible;
+  z-index: -1;
+}
+
+.alert-container {
+  position: absolute;
+  display: flex;
+  flex-direction: row;
+  height: 100vh;
+  width: 100vw;
+}
+
+.hide {
+  opacity: 0 !important;
+  transition: opacity 0.5s ease;
+}
+
+.show {
+  opacity: 1 !important;
+  transition: opacity 0.5s ease;
+}
+
+.middle {
+  align-items: center;
+}
+
+.top {
+  align-items: flex-start;
+}
+
+.bottom {
+  align-items: flex-end;
+}
+
+#alert-background {
+  left: 0;
+  margin: 0;
+  opacity: 0;
+  overflow: hidden;
+  padding: 0.5em 0;
+  position: absolute;
+  transition: opacity 0.5s ease;
+  white-space: nowrap;
+  width: 100%;
+  z-index: 11;
+}
+
+#alert-text {
+  margin: 0 0.5em;
+  opacity: 0;
+  overflow: visible;
+  padding: 0;
+  transition: opacity 0.5s linear;
+  z-index: 100;
+}

=== modified file 'openlp/core/display/html/display.html'
--- openlp/core/display/html/display.html	2019-03-17 10:36:12 +0000
+++ openlp/core/display/html/display.html	2019-09-12 22:43:33 +0000
@@ -2,34 +2,16 @@
 <html>
   <head>
     <title>Display Window</title>
-    <link href="reveal.css" rel="stylesheet">
-    <style type="text/css">
-    body {
-      background: transparent !important;
-      color: rgb(255, 255, 255) !important;
-    }
-    sup {
-      vertical-align: super !important;
-      font-size: smaller !important;
-    }
-    .reveal .slides > section,
-    .reveal .slides > section > section {
-      padding: 0;
-    }
-    .reveal > .backgrounds > .present {
-      visibility: hidden !important;
-    }
-    #global-background {
-      display: block;
-      visibility: visible;
-      z-index: -1;
-    }
-    </style>
+    <link type="text/css" rel="stylesheet" href="reveal.css">
+    <link type="text/css" rel="stylesheet" href="display.css">
     <script type="text/javascript" src="qrc:///qtwebchannel/qwebchannel.js"></script>
     <script type="text/javascript" src="reveal.js"></script>
     <script type="text/javascript" src="display.js"></script>
   </head>
   <body>
+    <div class="alert-container">
+      <div id="alert-background" class="hide"><div id="alert-text" class="hide">Testing alerts</div></div>
+    </div>
     <div class="reveal">
       <div id="global-background" class="slide-background present" data-loaded="true"></div>
       <div class="slides"></div>

=== modified file 'openlp/core/display/html/display.js'
--- openlp/core/display/html/display.js	2019-08-22 16:40:45 +0000
+++ openlp/core/display/html/display.js	2019-09-12 22:43:33 +0000
@@ -53,6 +53,50 @@
 };
 
 /**
+ * Transition state enumeration
+ */
+var TransitionState = {
+  EntranceTransition: "entranceTransition",
+  NoTransition: "noTransition",
+  ExitTransition: "exitTransition"
+};
+
+/**
+ * Animation state enumeration
+ */
+var AnimationState = {
+  NoAnimation: "noAnimation",
+  ScrollingText: "scrollingText",
+  NonScrollingText: "noScrollingText"
+};
+
+/**
+ * Alert location enumeration
+ */
+var AlertLocation = {
+  Top: 0,
+  Middle: 1,
+  Bottom: 2
+};
+
+/**
+ * Alert state enumeration
+ */
+var AlertState = {
+  Displaying: "displaying",
+  NotDisplaying: "notDisplaying"
+}
+
+/**
+ * Alert delay enumeration
+ */
+var AlertDelay = {
+  FiftyMilliseconds: 50,
+  OneSecond: 1000,
+  OnePointFiveSeconds: 1500
+}
+
+/**
  * Return an array of elements based on the selector query
  * @param {string} selector - The selector to find elements
  * @returns {array} An array of matching elements
@@ -118,6 +162,50 @@
 }
 
 /**
+ * Change a camelCaseString to a camel-case-string
+ * @private
+ * @param {string} text
+ * @returns {string} the Un-camel-case-ified string
+ */
+function _fromCamelCase(text) {
+  return text.replace(/([A-Z])/g, function (match, submatch) {
+    return '-' + submatch.toLowerCase();
+  });
+}
+
+/**
+ * Create a CSS style
+ * @private
+ * @param {string} selector - The selector for this style
+ * @param {Object} rules - The rules to apply to the style
+ */
+function _createStyle(selector, rules) {
+  var id = selector.replace("#", "").replace(" .", "-").replace(".", "-").replace(" ", "_");
+  if ($("style#" + id).length != 0) {
+    var style = $("style#" + id)[0];
+  }
+  else {
+    var style = document.createElement("style");
+    document.getElementsByTagName("head")[0].appendChild(style);
+    style.type = "text/css";
+    style.id = id;
+  }
+  var rulesString = selector + " { ";
+  for (var key in rules) {
+    var ruleValue = rules[key];
+    var ruleKey = _fromCamelCase(key);
+    rulesString += "" + ruleKey + ": " + ruleValue + ";";
+  }
+  rulesString += " } ";
+  if (style.styleSheet) {
+    style.styleSheet.cssText = rulesString;
+  }
+  else {
+    style.appendChild(document.createTextNode(rulesString));
+  }
+}
+
+/**
  * An audio player with a play list
  */
 var AudioPlayer = function (audioElement) {
@@ -234,7 +322,12 @@
  * The Display object is what we use from OpenLP
  */
 var Display = {
+  _alerts: [],
   _slides: {},
+  _alertSettings: {},
+  _alertState: AlertState.NotDisplaying,
+  _transitionState: TransitionState.NoTransition,
+  _animationState: AnimationState.NoAnimation,
   _revealConfig: {
     margin: 0.0,
     minScale: 1.0,
@@ -355,21 +448,149 @@
     Display.reinit();
   },
   /**
-   * Display an alert
-   * @param {string} text - The alert text
-   * @param {int} location - The location of the text (top, middle or bottom)
-  */
- alert: function (text, location) {
-  console.debug(" alert text: " + text, ", location: " + location);
-  /*
-   * The implementation should show an alert.
-   * It should be able to handle receiving a new alert before a previous one is "finished", basically queueing it.
-   */
-  return;
-},
-
-  /**
-   * Add a slides. If the slide exists but the HTML is different, update the slide.
+   * Display an alert. If there's an alert already showing, add this one to the queue
+   * @param {string} text - The alert text
+   * @param {Object} JSON object - The settings for the alert object
+  */
+  alert: function (text, settings) {
+    if (text == "") {
+      return null;
+    }
+    if (Display._alertState === AlertState.Displaying) {
+      console.debug("Adding to queue");
+      Display.addAlertToQueue(text, settings);
+    }
+    else {
+      console.debug("Displaying immediately");
+      Display.showAlert(text, settings);
+    }
+  },
+  /**
+   * Show the alert on the screen
+   * @param {string} text - The alert text
+   * @param {Object} JSON object - The settings for the alert
+  */
+  showAlert: function (text, settings) {
+    var alertBackground = $('#alert-background')[0];
+    var alertText = $('#alert-text')[0];
+    // create styles for the alerts from the settings
+    _createStyle("#alert-background.settings", {
+      backgroundColor: settings["backgroundColor"],
+      fontFamily: "'" + settings["fontFace"] + "'",
+      fontSize: settings["fontSize"].toString() + "pt",
+      color: settings["fontColor"]
+    });
+    alertBackground.classList.add("settings");
+    alertBackground.classList.replace("hide", "show");
+    alertText.innerHTML = text;
+    Display.setAlertLocation(settings.location);
+    Display._transitionState = TransitionState.EntranceTransition;
+    /* Check if the alert is a queued alert */
+    if (Display._alertState !== AlertState.Displaying) {
+      Display._alertState = AlertState.Displaying;
+    }
+    alertBackground.addEventListener('transitionend', Display.alertTransitionEndEvent, false);
+    alertText.addEventListener('animationend', Display.alertAnimationEndEvent, false);
+    /* Either scroll the alert, or make it disappear at the end of its time */
+    if (settings.scroll) {
+      Display._animationState = AnimationState.ScrollingText;
+      var animationSettings = "alert-scrolling-text " + settings.timeout +
+                              "s linear 0.6s " + settings.repeat + " normal";
+      alertText.style.animation = animationSettings;
+    }
+    else {
+      Display._animationState = AnimationState.NonScrollingText;
+      alertText.classList.replace("hide", "show");
+      setTimeout (function () {
+        Display._animationState = AnimationState.NoAnimation;
+        Display.hideAlert();
+      }, settings.timeout * AlertDelay.OneSecond);
+    }
+  },
+  /**
+   * Hide the alert at the end
+   */
+  hideAlert: function () {
+    var alertBackground = $('#alert-background')[0];
+    var alertText = $('#alert-text')[0];
+    Display._transitionState = TransitionState.ExitTransition;
+    alertText.classList.replace("show", "hide");
+    alertBackground.classList.replace("show", "hide");
+    alertText.style.animation = "";
+    Display._alertState = AlertState.NotDisplaying;
+  },
+  /**
+   * Add an alert to the alert queue
+   * @param {string} text - The alert text to be displayed
+   * @param {Object} setttings - JSON object containing the settings for the alert
+   */
+  addAlertToQueue: function (text, settings) {
+    Display._alerts.push({text: text, settings: settings});
+  },
+  /**
+   * The alertTransitionEndEvent called after a transition has ended
+   */
+  alertTransitionEndEvent: function (e) {
+    e.stopPropagation();
+    console.debug("Transition end event reached: " + Display._transitionState);
+    if (Display._transitionState === TransitionState.EntranceTransition) {
+      Display._transitionState = TransitionState.NoTransition;
+    }
+    else if (Display._transitionState === TransitionState.ExitTransition) {
+      Display._transitionState = TransitionState.NoTransition;
+      Display.hideAlert();
+      Display.showNextAlert();
+    }
+  },
+  /**
+   * The alertAnimationEndEvent called after an animation has ended
+   */
+  alertAnimationEndEvent: function (e) {
+    e.stopPropagation();
+    Display.hideAlert();
+  },
+  /**
+   * Set the location of the alert
+   * @param {int} location - Integer number with the location of the alert on screen
+   */
+  setAlertLocation: function (location) {
+    var alertContainer = $(".alert-container")[0];
+    // Remove an existing location classes
+    alertContainer.classList.remove("top");
+    alertContainer.classList.remove("middle");
+    alertContainer.classList.remove("bottom");
+    // Apply the location class we want
+    switch (location) {
+      case AlertLocation.Top:
+        alertContainer.classList.add("top");
+        break;
+      case AlertLocation.Middle:
+        alertContainer.classList.add("middle");
+        break;
+      case AlertLocation.Bottom:
+      default:
+        alertContainer.classList.add("bottom");
+        break;
+    }
+  },
+  /**
+  * Display the next alert in the queue
+  */
+  showNextAlert: function () {
+    console.log("showNextAlert");
+    if (Display._alerts.length > 0) {
+      console.log("Showing next alert");
+      var alertObject = Display._alerts.shift();
+      Display._alertState = AlertState.DisplayingFromQueue;
+      Display.showAlert(alertObject.text, alertObject.settings);
+    }
+    else {
+      // For the tests
+      return null;
+    }
+  },
+  /**
+   * Add a slide. If the slide exists but the HTML is different, update the slide.
    * @param {string} verse - The verse number, e.g. "v1"
    * @param {string} text - The HTML for the verse, e.g. "line1<br>line2"
    * @param {string} footer_text - The HTML for the footer"
@@ -770,7 +991,7 @@
     return videoTypes;
   },
   /**
-   * Sets the scale of the page - used to make preview widgets scale 
+   * Sets the scale of the page - used to make preview widgets scale
    */
   setScale: function(scale) {
     document.body.style.zoom = scale+"%";

=== modified file 'openlp/core/display/webengine.py'
--- openlp/core/display/webengine.py	2019-04-13 13:00:22 +0000
+++ openlp/core/display/webengine.py	2019-09-12 22:43:33 +0000
@@ -27,6 +27,8 @@
 
 from PyQt5 import QtCore, QtWebEngineWidgets, QtWidgets
 
+from openlp.core.common.applocation import AppLocation
+
 
 LOG_LEVELS = {
     QtWebEngineWidgets.QWebEnginePage.InfoMessageLevel: logging.INFO,
@@ -46,6 +48,10 @@
         """
         Override the parent method in order to log the messages in OpenLP
         """
+        # The JS log has the entire file location, which we don't really care about
+        app_dir = AppLocation.get_directory(AppLocation.AppDir).parent
+        source_id = source_id.replace('file://{app_dir}/'.format(app_dir=app_dir), '')
+        # Log the JS messages to the Python logger
         log.log(LOG_LEVELS[level], '{source_id}:{line_number} {message}'.format(source_id=source_id,
                                                                                 line_number=line_number,
                                                                                 message=message))

=== modified file 'openlp/core/display/window.py'
--- openlp/core/display/window.py	2019-09-10 21:37:12 +0000
+++ openlp/core/display/window.py	2019-09-12 22:43:33 +0000
@@ -399,8 +399,8 @@
         self.scale = scale
         self.run_javascript('Display.setScale({scale});'.format(scale=scale * 100))
 
-    def alert(self, text, location):
+    def alert(self, text, settings):
         """
         Set an alert
         """
-        self.run_javascript('Display.alert({text}, {location});'.format(text=text, location=location))
+        self.run_javascript('Display.alert("{text}", {settings});'.format(text=text, settings=settings))

=== modified file 'openlp/plugins/alerts/alertsplugin.py'
--- openlp/plugins/alerts/alertsplugin.py	2019-04-13 13:00:22 +0000
+++ openlp/plugins/alerts/alertsplugin.py	2019-09-12 22:43:33 +0000
@@ -126,7 +126,9 @@
     'alerts/location': AlertLocation.Bottom,
     'alerts/background color': '#660000',
     'alerts/font color': '#ffffff',
-    'alerts/timeout': 5
+    'alerts/timeout': 10,
+    'alerts/repeat': 1,
+    'alerts/scroll': True
 }
 
 

=== modified file 'openlp/plugins/alerts/lib/alertsmanager.py'
--- openlp/plugins/alerts/lib/alertsmanager.py	2019-04-13 13:00:22 +0000
+++ openlp/plugins/alerts/lib/alertsmanager.py	2019-09-12 22:43:33 +0000
@@ -23,7 +23,9 @@
 The :mod:`~openlp.plugins.alerts.lib.alertsmanager` module contains the part of the plugin which manages storing and
 displaying of alerts.
 """
-from PyQt5 import QtCore
+import json
+
+from PyQt5 import QtCore, QtGui
 
 from openlp.core.common.i18n import translate
 from openlp.core.common.mixins import LogMixin, RegistryProperties
@@ -83,8 +85,26 @@
                                    not Settings().value('core/display on monitor')):
             return
         text = self.alert_list.pop(0)
-        alert_tab = self.parent().settings_tab
-        self.live_controller.displays[0].alert(text, alert_tab.location)
+
+        # Get the rgb color format of the font & background hex colors from settings
+        rgb_font_color = self.hex_to_rgb(QtGui.QColor(Settings().value('alerts/font color')))
+        rgb_background_color = self.hex_to_rgb(QtGui.QColor(Settings().value('alerts/background color')))
+
+        # Put alert settings together in dict that will be passed to Display in Javascript
+        alert_settings = {
+            'backgroundColor': rgb_background_color,
+            'location': Settings().value('alerts/location'),
+            'fontFace': Settings().value('alerts/font face'),
+            'fontSize': Settings().value('alerts/font size'),
+            'fontColor': rgb_font_color,
+            'timeout': Settings().value('alerts/timeout'),
+            'repeat': Settings().value('alerts/repeat'),
+            'scroll': Settings().value('alerts/scroll')
+        }
+        self.live_controller.displays[0].alert(text, json.dumps(alert_settings))
+        # Check to see if we have a timer running.
+        # if self.timer_id == 0:
+        #    self.timer_id = self.startTimer(int(alert_tab.timeout) * 1000)
 
     def timerEvent(self, event):
         """
@@ -98,3 +118,13 @@
         self.killTimer(self.timer_id)
         self.timer_id = 0
         self.generate_alert()
+
+    def hex_to_rgb(self, rgb_values):
+        """
+        Converts rgb color values from QColor to rgb string
+
+        :param rgb_values:
+        :return: rgb color string
+        :rtype: string
+        """
+        return "rgb(" + str(rgb_values.red()) + ", " + str(rgb_values.green()) + ", " + str(rgb_values.blue()) + ")"

=== modified file 'openlp/plugins/alerts/lib/alertstab.py'
--- openlp/plugins/alerts/lib/alertstab.py	2019-04-13 13:00:22 +0000
+++ openlp/plugins/alerts/lib/alertstab.py	2019-09-12 22:43:33 +0000
@@ -47,35 +47,56 @@
         self.font_layout.addRow(self.font_label, self.font_combo_box)
         self.font_color_label = QtWidgets.QLabel(self.font_group_box)
         self.font_color_label.setObjectName('font_color_label')
-        self.color_layout = QtWidgets.QHBoxLayout()
-        self.color_layout.setObjectName('color_layout')
         self.font_color_button = ColorButton(self.font_group_box)
         self.font_color_button.setObjectName('font_color_button')
-        self.color_layout.addWidget(self.font_color_button)
-        self.color_layout.addSpacing(20)
-        self.background_color_label = QtWidgets.QLabel(self.font_group_box)
-        self.background_color_label.setObjectName('background_color_label')
-        self.color_layout.addWidget(self.background_color_label)
-        self.background_color_button = ColorButton(self.font_group_box)
-        self.background_color_button.setObjectName('background_color_button')
-        self.color_layout.addWidget(self.background_color_button)
-        self.font_layout.addRow(self.font_color_label, self.color_layout)
+        self.font_layout.addRow(self.font_color_label, self.font_color_button)
         self.font_size_label = QtWidgets.QLabel(self.font_group_box)
         self.font_size_label.setObjectName('font_size_label')
         self.font_size_spin_box = QtWidgets.QSpinBox(self.font_group_box)
         self.font_size_spin_box.setObjectName('font_size_spin_box')
         self.font_layout.addRow(self.font_size_label, self.font_size_spin_box)
-        self.timeout_label = QtWidgets.QLabel(self.font_group_box)
+        self.left_layout.addWidget(self.font_group_box)
+        # Background Settings
+        self.background_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.background_group_box.setObjectName('background_group_box')
+        self.background_layout = QtWidgets.QFormLayout(self.background_group_box)
+        self.background_layout.setObjectName('background_settings_layout')
+        self.background_color_label = QtWidgets.QLabel(self.background_group_box)
+        self.background_color_label.setObjectName('background_color_label')
+        self.background_color_button = ColorButton(self.background_group_box)
+        self.background_color_button.setObjectName('background_color_button')
+        self.background_layout.addRow(self.background_color_label,self.background_color_button)
+        self.left_layout.addWidget(self.background_group_box)
+        # Scroll Settings
+        self.scroll_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.scroll_group_box.setObjectName('scroll_group_box')
+        self.scroll_group_layout = QtWidgets.QFormLayout(self.scroll_group_box)
+        self.scroll_group_layout.setObjectName('scroll_group_layout')
+        self.scroll_check_box = QtWidgets.QCheckBox(self.scroll_group_box)
+        self.scroll_check_box.setObjectName('scroll_check_box')
+        self.scroll_group_layout.addRow(self.scroll_check_box)
+        self.repeat_label = QtWidgets.QLabel(self.scroll_group_box)
+        self.repeat_label.setObjectName('repeat_label')
+        self.repeat_spin_box = QtWidgets.QSpinBox(self.scroll_group_box)
+        self.repeat_spin_box.setObjectName('repeat_spin_box')
+        self.scroll_group_layout.addRow(self.repeat_label, self.repeat_spin_box)
+        self.left_layout.addWidget(self.scroll_group_box)
+        # Other Settings
+        self.settings_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.settings_group_box.setObjectName('settings_group_box')
+        self.settings_layout = QtWidgets.QFormLayout(self.settings_group_box)
+        self.settings_layout.setObjectName('settings_layout')
+        self.timeout_label = QtWidgets.QLabel(self.settings_group_box)
         self.timeout_label.setObjectName('timeout_label')
-        self.timeout_spin_box = QtWidgets.QSpinBox(self.font_group_box)
+        self.timeout_spin_box = QtWidgets.QSpinBox(self.settings_group_box)
         self.timeout_spin_box.setMaximum(180)
         self.timeout_spin_box.setObjectName('timeout_spin_box')
-        self.font_layout.addRow(self.timeout_label, self.timeout_spin_box)
+        self.settings_layout.addRow(self.timeout_label, self.timeout_spin_box)
         self.vertical_label, self.vertical_combo_box = create_valign_selection_widgets(self.font_group_box)
         self.vertical_label.setObjectName('vertical_label')
         self.vertical_combo_box.setObjectName('vertical_combo_box')
-        self.font_layout.addRow(self.vertical_label, self.vertical_combo_box)
-        self.left_layout.addWidget(self.font_group_box)
+        self.settings_layout.addRow(self.vertical_label, self.vertical_combo_box)
+        self.left_layout.addWidget(self.settings_group_box)
         self.left_layout.addStretch()
         self.preview_group_box = QtWidgets.QGroupBox(self.right_column)
         self.preview_group_box.setObjectName('preview_group_box')
@@ -92,16 +113,22 @@
         self.font_combo_box.activated.connect(self.on_font_combo_box_clicked)
         self.timeout_spin_box.valueChanged.connect(self.on_timeout_spin_box_changed)
         self.font_size_spin_box.valueChanged.connect(self.on_font_size_spin_box_changed)
+        self.repeat_spin_box.valueChanged.connect(self.on_repeat_spin_box_changed)
+        self.scroll_check_box.toggled.connect(self.scroll_check_box_toggled)
 
     def retranslate_ui(self):
-        self.font_group_box.setTitle(translate('AlertsPlugin.AlertsTab', 'Font'))
+        self.font_group_box.setTitle(translate('AlertsPlugin.AlertsTab', 'Font Settings'))
         self.font_label.setText(translate('AlertsPlugin.AlertsTab', 'Font name:'))
         self.font_color_label.setText(translate('AlertsPlugin.AlertsTab', 'Font color:'))
         self.background_color_label.setText(UiStrings().BackgroundColorColon)
         self.font_size_label.setText(translate('AlertsPlugin.AlertsTab', 'Font size:'))
         self.font_size_spin_box.setSuffix(' {unit}'.format(unit=UiStrings().FontSizePtUnit))
+        self.background_group_box.setTitle(translate('AlertsPlugin.AlertsTab', 'Background Settings'))
+        self.settings_group_box.setTitle(translate('AlertsPlugin.AlertsTab', 'Other Settings'))
         self.timeout_label.setText(translate('AlertsPlugin.AlertsTab', 'Alert timeout:'))
         self.timeout_spin_box.setSuffix(' {unit}'.format(unit=UiStrings().Seconds))
+        self.repeat_label.setText(translate('AlertsPlugin.AlertsTab', 'Repeat (no. of times):'))
+        self.scroll_check_box.setText(translate('AlertsPlugin.AlertsTab', 'Enable Scrolling'))
         self.preview_group_box.setTitle(UiStrings().Preview)
         self.font_preview.setText(UiStrings().OpenLP)
 
@@ -140,6 +167,24 @@
         self.font_size = self.font_size_spin_box.value()
         self.update_display()
 
+    def on_repeat_spin_box_changed(self):
+        """
+        The repeat spin box has changed
+        """
+        self.repeat = self.repeat_spin_box.value()
+        self.changed = True
+
+    def scroll_check_box_toggled(self):
+        """
+        The scrolling checkbox has been toggled
+        """
+        if self.scroll_check_box.isChecked():
+            self.repeat_spin_box.setEnabled(True)
+        else:
+            self.repeat_spin_box.setEnabled(False)
+        self.scroll = self.scroll_check_box.isChecked()
+        self.changed = True
+
     def load(self):
         """
         Load the settings into the UI.
@@ -152,12 +197,17 @@
         self.background_color = settings.value('background color')
         self.font_face = settings.value('font face')
         self.location = settings.value('location')
+        self.repeat = settings.value('repeat')
+        self.scroll = settings.value('scroll')
         settings.endGroup()
         self.font_size_spin_box.setValue(self.font_size)
         self.timeout_spin_box.setValue(self.timeout)
         self.font_color_button.color = self.font_color
         self.background_color_button.color = self.background_color
+        self.repeat_spin_box.setValue(self.repeat)
+        self.repeat_spin_box.setEnabled(self.scroll)
         self.vertical_combo_box.setCurrentIndex(self.location)
+        self.scroll_check_box.setChecked(self.scroll)
         font = QtGui.QFont()
         font.setFamily(self.font_face)
         self.font_combo_box.setCurrentFont(font)
@@ -181,6 +231,8 @@
         settings.setValue('timeout', self.timeout)
         self.location = self.vertical_combo_box.currentIndex()
         settings.setValue('location', self.location)
+        settings.setValue('repeat', self.repeat)
+        settings.setValue('scroll', self.scroll_check_box.isChecked())
         settings.endGroup()
         if self.changed:
             self.settings_form.register_post_process('update_display_css')

=== modified file 'tests/js/test_display.js'
--- tests/js/test_display.js	2019-08-22 16:40:45 +0000
+++ tests/js/test_display.js	2019-09-12 22:43:33 +0000
@@ -27,6 +27,14 @@
   it("AudioState should exist", function () {
     expect(AudioState).toBeDefined();
   });
+
+  it("TransitionState should exist", function(){
+    expect(TransitionState).toBeDefined();
+  });
+
+  it("AnimationState should exist", function(){
+    expect(AnimationState).toBeDefined();
+  });
 });
 
 describe("The function", function () {
@@ -141,6 +149,296 @@
     Display.goToSlide("v1");
     expect(Reveal.slide).toHaveBeenCalledWith(0);
   });
+
+  it("should have an alert() method", function () {
+    expect(Display.alert).toBeDefined();
+  });
+
+});
+
+describe("Display.alert", function () {
+  var alertContainer, alertBackground, alertText, settings, text;
+
+  beforeEach(function () {
+    document.body.innerHTML = "";
+    alertContainer = _createDiv({"class": "alert-container"});
+    alertBackground = _createDiv({"id": "alert-background", "class": "hide"});
+    alertText = _createDiv({"id": "alert-text", "class": "hide"});
+    settings = {
+      "location": 1,
+      "fontFace": "sans-serif",
+      "fontSize": 40,
+      "fontColor": "#ffffff",
+      "backgroundColor": "#660000",
+      "timeout": 5,
+      "repeat": 1,
+      "scroll": true
+    };
+    text = "Display.alert";
+  });
+
+  it("should return null if called without any text", function () {
+    expect(Display.alert("", settings)).toBeNull();
+  });
+
+  it("should set the correct alert text", function () {
+    spyOn(Display, "showAlert");
+
+    Display.alert(text, settings);
+
+    expect(Display.showAlert).toHaveBeenCalled();
+  });
+
+  it("should call the addAlertToQueue method if an alert is displaying", function () {
+    spyOn(Display, "addAlertToQueue");
+    Display._alerts = [];
+    Display._alertState = AlertState.Displaying;
+
+    Display.alert(text, settings);
+
+    expect(Display.addAlertToQueue).toHaveBeenCalledWith(text, settings);
+  });
+});
+
+describe("Display.showAlert", function () {
+  var alertContainer, alertBackground, alertText, settings;
+
+  beforeEach(function () {
+    document.body.innerHTML = "";
+    alertContainer = _createDiv({"class": "alert-container"});
+    alertBackground = _createDiv({"id": "alert-background", "class": "hide"});
+    alertText = _createDiv({"id": "alert-text", "class": "hide"});
+    settings = {
+      "location": 1,
+      "fontFace": "sans-serif",
+      "fontSize": 40,
+      "fontColor": "#ffffff",
+      "backgroundColor": "#660000",
+      "timeout": 5,
+      "repeat": 1,
+      "scroll": true
+    };
+  });
+
+  it("should create a stylesheet for the settings", function () {
+    spyOn(window, "_createStyle");
+    Display.showAlert("Test Display.showAlert - stylesheet", settings);
+
+    expect(_createStyle).toHaveBeenCalledWith("#alert-background.settings", {
+      backgroundColor: settings["backgroundColor"],
+      fontFamily: "'" + settings["fontFace"] + "'",
+      fontSize: settings["fontSize"] + 'pt',
+      color: settings["fontColor"]
+    });
+  });
+
+  it("should set the alert state to AlertState.Displaying", function () {
+    Display.showAlert("Test Display.showAlert - state", settings);
+
+    expect(Display._alertState).toEqual(AlertState.Displaying);
+  });
+
+  it("should remove the 'hide' classes and add the 'show' classes", function () {
+    Display.showAlert("Test Display.showAlert - classes", settings);
+
+    expect($("#alert-background")[0].classList.contains("hide")).toEqual(false);
+    expect($("#alert-background")[0].classList.contains("show")).toEqual(true);
+    //expect($("#alert-text")[0].classList.contains("hide")).toEqual(false);
+    //expect($("#alert-text")[0].classList.contains("show")).toEqual(true);
+  });
+});
+
+describe("Display.hideAlert", function () {
+  var alertContainer, alertBackground, alertText, settings;
+
+  beforeEach(function () {
+    document.body.innerHTML = "";
+    alertContainer = _createDiv({"class": "alert-container"});
+    alertBackground = _createDiv({"id": "alert-background", "class": "hide"});
+    alertText = _createDiv({"id": "alert-text", "class": "hide"});
+    settings = {
+      "location": 1,
+      "fontFace": "sans-serif",
+      "fontSize": 40,
+      "fontColor": "#ffffff",
+      "backgroundColor": "#660000",
+      "timeout": 5,
+      "repeat": 1,
+      "scroll": true
+    };
+  });
+
+  it("should set the alert state to AlertState.NotDisplaying", function () {
+    Display.showAlert("test", settings);
+
+    Display.hideAlert();
+
+    expect(Display._alertState).toEqual(AlertState.NotDisplaying);
+  });
+
+  it("should hide the alert divs when called", function() {
+    Display.showAlert("test", settings);
+
+    Display.hideAlert();
+
+    expect(Display._transitionState).toEqual(TransitionState.ExitTransition);
+    expect(alertBackground.classList.contains("hide")).toEqual(true);
+    expect(alertBackground.classList.contains("show")).toEqual(false);
+    expect(alertText.classList.contains("hide")).toEqual(true);
+    expect(alertText.classList.contains("show")).toEqual(false);
+  });
+});
+
+describe("Display.setAlertLocation", function() {
+  var alertContainer, alertBackground, alertText, settings;
+
+  beforeEach(function () {
+    document.body.innerHTML = "";
+    alertContainer = _createDiv({"class": "alert-container"});
+    alertBackground = _createDiv({"id": "alert-background", "class": "hide"});
+    alertText = _createDiv({"id": "alert-text", "class": "hide"});
+    settings = {
+      "location": 1,
+      "fontFace": "sans-serif",
+      "fontSize": 40,
+      "fontColor": "#ffffff",
+      "backgroundColor": "#660000",
+      "timeout": 5,
+      "repeat": 1,
+      "scroll": true
+    };
+  });
+
+  it("should set the correct class when location is top of the page", function () {
+    Display.setAlertLocation(0);
+
+    expect(alertContainer.className).toEqual("alert-container top");
+  });
+
+  it("should set the correct class when location is middle of the page", function () {
+    Display.setAlertLocation(1);
+
+    expect(alertContainer.className).toEqual("alert-container middle");
+  });
+
+  it("should set the correct class when location is bottom of the page", function () {
+    Display.setAlertLocation(2);
+
+    expect(alertContainer.className).toEqual("alert-container bottom");
+  });
+});
+
+describe("Display.addAlertToQueue", function () {
+  var alertContainer, alertBackground, alertText, settings;
+
+  beforeEach(function () {
+    document.body.innerHTML = "";
+    alertContainer = _createDiv({"class": "alert-container"});
+    alertBackground = _createDiv({"id": "alert-background", "class": "hide"});
+    alertText = _createDiv({"id": "alert-text", "class": "hide"});
+    settings = {
+      "location": 1,
+      "fontFace": "sans-serif",
+      "fontSize": 40,
+      "fontColor": "#ffffff",
+      "backgroundColor": "#660000",
+      "timeout": 5,
+      "repeat": 1,
+      "scroll": true
+    };
+  });
+
+  it("should add an alert to the queue if one is displaying already", function() {
+    Display._alerts = [];
+    Display._alertState = AlertState.Displaying;
+    var alertObject = {text: "Testing alert queue", settings: settings};
+
+    Display.addAlertToQueue("Testing alert queue", settings);
+
+    expect(Display._alerts.length).toEqual(1);
+    expect(Display._alerts[0]).toEqual(alertObject);
+  });
+});
+
+describe("Display.showNextAlert", function () {
+  var alertContainer, alertBackground, alertText, settings;
+
+  beforeEach(function () {
+    document.body.innerHTML = "";
+    alertContainer = _createDiv({"class": "alert-container"});
+    alertBackground = _createDiv({"id": "alert-background", "class": "hide"});
+    alertText = _createDiv({"id": "alert-text", "class": "hide"});
+    settings = {
+      "location": 1,
+      "fontFace": "sans-serif",
+      "fontSize": 40,
+      "fontColor": "#ffffff",
+      "backgroundColor": "#660000",
+      "timeout": 5,
+      "repeat": 1,
+      "scroll": true
+    };
+  });
+
+  it("should return null if there are no alerts in the queue", function () {
+    Display._alerts = [];
+    Display.showNextAlert();
+
+    expect(Display.showNextAlert()).toBeNull();
+  });
+
+  it("should call the alert function correctly if there is an alert in the queue", function () {
+    Display._alerts.push({text: "Queued Alert", settings: settings});
+    spyOn(Display, "showAlert");
+    Display.showNextAlert();
+
+    expect(Display.showAlert).toHaveBeenCalled();
+    expect(Display.showAlert).toHaveBeenCalledWith("Queued Alert", settings);
+  });
+});
+
+describe("Display.alertTransitionEndEvent", function() {
+  var e = { stopPropagation: function () { } };
+
+  it("should call event.stopPropagation()", function () {
+    spyOn(e, "stopPropagation");
+
+    Display.alertTransitionEndEvent(e);
+
+    expect(e.stopPropagation).toHaveBeenCalled();
+  });
+
+  it("should set the correct state after EntranceTransition", function() {
+    Display._transitionState = TransitionState.EntranceTransition;
+
+    Display.alertTransitionEndEvent(e);
+
+    expect(Display._transitionState).toEqual(TransitionState.NoTransition);
+  });
+
+  it("should set the correct state after ExitTransition, call hideAlert() and showNextAlert()", function() {
+    spyOn(Display, "hideAlert");
+    spyOn(Display, "showNextAlert");
+    Display._transitionState = TransitionState.ExitTransition;
+
+    Display.alertTransitionEndEvent(e);
+
+    expect(Display._transitionState).toEqual(TransitionState.NoTransition);
+    expect(Display.hideAlert).toHaveBeenCalled();
+    expect(Display.showNextAlert).toHaveBeenCalled();
+  });
+});
+
+describe("Display.alertAnimationEndEvent", function () {
+  var e = { stopPropagation: function () { } };
+
+  it("should call the hideAlert method", function() {
+    spyOn(Display, "hideAlert");
+
+    Display.alertAnimationEndEvent(e);
+
+    expect(Display.hideAlert).toHaveBeenCalled();
+  });
 });
 
 describe("Display.addTextSlide", function () {
@@ -181,7 +479,7 @@
 
   it("should update an existing slide", function () {
     var verse = "v1",
-        text = "Amazing grace, how sweet the sound\nThat saved a wretch like me", 
+        text = "Amazing grace, how sweet the sound\nThat saved a wretch like me",
         footer = "Public Domain";
     Display.addTextSlide(verse, "Amazing grace,\nhow sweet the sound", footer, false);
     spyOn(Display, "reinit");
@@ -249,6 +547,7 @@
       'font_main_outline_color': 'red'
     };
     spyOn(Display, "reinit");
+    spyOn(Reveal, "slide");
 
     Display.setTextSlides(slides);
     Display.setTheme(theme);


Follow ups