/**
 * display.js is the main Javascript file that is used to drive the display.
 */


/**
 * Background type enumeration
 */
var BackgroundType = {
  Transparent: "transparent",
  Solid: "solid",
  Gradient: "gradient",
  Video: "video",
  Image: "image"
};

/**
 * Gradient type enumeration
 */
var GradientType = {
  Horizontal: "horizontal",
  LeftTop: "leftTop",
  LeftBottom: "leftBottom",
  Vertical: "vertical",
  Circular: "circular"
};

/**
 * Horizontal alignment enumeration
 */
var HorizontalAlign = {
  Left: 0,
  Right: 1,
  Center: 2,
  Justify: 3
};

/**
 * Vertical alignment enumeration
 */
var VerticalAlign = {
  Top: 0,
  Middle: 1,
  Bottom: 2
};

/**
 * Transition type enumeration
 */
var TransitionType = {
  Fade: 0,
  Slide: 1,
  Convex: 2,
  Concave: 3,
  Zoom: 4
};

/**
 * Transition speed enumeration
 */
var TransitionSpeed = {
  Normal: 0,
  Fast: 1,
  Slow: 2
};


/**
 * Transition direction enumeration
 */
var TransitionDirection = {
  Horizontal: 0,
  Vertical: 1
};

/**
 * Audio state enumeration
 */
var AudioState = {
  Playing: "playing",
  Paused: "paused",
  Stopped: "stopped"
};

/**
 * 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
 */
function $(selector) {
  return Array.from(document.querySelectorAll(selector));
}

/**
 * Build linear gradient CSS
 * @private
 * @param {string} direction - The direction or angle of the gradient e.g. to bottom or 90deg
 * @param {string} startColor - The starting color
 * @param {string} endColor - The ending color
 * @returns {string} A string of the gradient CSS
 */
function _buildLinearGradient(direction, startColor, endColor) {
  return "linear-gradient(" + direction + ", " + startColor + ", " + endColor + ") fixed";
}

/**
 * Build radial gradient CSS
 * @private
 * @param {string} width - Width of the gradient
 * @param {string} startColor - The starting color
 * @param {string} endColor - The ending color
 * @returns {string} A string of the gradient CSS
 */
function _buildRadialGradient(width, startColor, endColor) {
  return "radial-gradient(" + startColor + ", " + endColor + ") fixed";
}

/**
 * Build a set of text shadows to form an outline
 * @private
 * @param {Number} size - The desired width of the outline
 * @param {string} color - The color of the outline
 * @returns {array} A list of shadows to be given to "text-shadow"
 */
function _buildTextOutline(size, color) {
  let shadows = [];
  // Outlines work from -(size) to +(size)
  let from = size * -1;
  // Loop through all the possible size iterations and add them to the array
  for (let i = from; i <= size; i++) {
    for (let j = from; j <= size; j++) {
      shadows.push(color + " " + i + "pt " + j + "pt 0pt");
    }
  }
  return shadows;
}

/**
 * Build a text shadow
 * @private
 * @param {Number} offset - The offset of the shadow
 * @param {string} color - The color that the shadow should be
 * @returns {string} The text-shadow rule
 */
function _buildTextShadow(offset, size, color) {
  let shadows = [];
  let from = (size * -1) + offset;
  let to = size + offset;
  for (let i = from; i <= to; i++) {
    for (let j = from; j <= to; j++) {
      shadows.push(color + " " + i + "pt " + j + "pt 0pt");
    }
  }
  return shadows.join(", ");
}

/**
 * Get a style value from an element (computed or manual)
 * @private
 * @param {Object} element - The element whose style we want
 * @param {string} style - The name of the style we want
 * @returns {(Number|string)} The style value (type depends on the style)
 */
function _getStyle(element, style) {
  return document.defaultView.getComputedStyle(element).getPropertyValue(style);
}

/**
 * Convert newlines to <br> tags
 * @private
 * @param {string} text - The text to parse
 * @returns {string} The text now with <br> tags
 */
function _nl2br(text) {
  return text.replace("\r\n", "\n").replace("\n", "<br>");
}

/**
 * Prepare text by creating paragraphs and calling _nl2br to convert newlines to <br> tags
 * @private
 * @param {string} text - The text to parse
 * @returns {string} The text now with <p> and <br> tags
 */
function _prepareText(text) {
  return "<p>" + _nl2br(text) + "</p>";
}

/**
 * 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 style;
  var id = selector.replace("#", "").replace(" .", "-").replace(".", "-").replace(" ", "_");
  if ($("style#" + id).length != 0) {
    style = $("style#" + id)[0];
  }
  else {
    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));
  }
}

/**
 * Fixes font name to match CSS names.
 * @param {string} fontName Font Name
 * @returns Fixed Font Name
 */
function _fixFontName(fontName) {
  if (!fontName || (fontName == 'Sans Serif')) {
    return 'sans-serif';
  }

  return "'" + fontName + "'";
}

/**
 * The Display object is what we use from OpenLP
 */
var Display = {
  /** @type {HTMLElement} */
  _slidesContainer: null,
  /** @type {HTMLElement} */
  _footerContainer: null,
  /** @type {HTMLElement} */
  _backgroundsContainer: null,
  _alerts: [],
  _slides: {},
  _alertSettings: {},
  _alertState: AlertState.NotDisplaying,
  _transitionState: TransitionState.NoTransition,
  _animationState: AnimationState.NoAnimation,
  _doTransitions: false,
  _doItemTransitions: false,
  _skipNextTransition: false,
  _themeApplied: true,
  _revealConfig: {
    margin: 0.0,
    minScale: 1.0,
    maxScale: 1.0,
    controls: false,
    progress: false,
    history: false,
    keyboard: false,
    overview: false,
    center: false,
    touch: false,
    help: false,
    transition: "none",
    backgroundTransition: "none",
    viewDistance: 9999,
    width: "100%",
    height: "100%"
  },
  _lastRequestAnimationFrameHandle: null,

  /**
   * Start up reveal and do any other initialisation
   * @param {object} options - The initialisation options:
   *                           * {bool} isDisplay         - Is this a real display output
   *                           * {bool} doItemTransitions - Transition between service items
   *                           * {bool} hideMouse         - Hide the cursor when hovering on this display
   */
  init: function (options) {
    // Set defaults for undefined values
    options = options || {};
    let isDisplay = options.isDisplay || false;
    let doItemTransitions = options.doItemTransitions || false;
    let hideMouse = options.hideMouse || false;
    if (options.slideNumbersInFooter) {
      Display._revealConfig.slideNumber = Display.setFooterSlideNumbers;
    }

    // Now continue to initialisation
    if (!isDisplay) {
      document.body.classList.add('checkerboard');
    }
    if (hideMouse) {
      document.body.classList.add('hide-mouse');
    }
    Display._slidesContainer = $(".slides")[0];
    Display._footerContainer = $(".footer")[0];
    Display._backgroundsContainer = $(".backgrounds")[0];
    Display._doTransitions = isDisplay;
    Reveal.initialize(Display._revealConfig);
    Reveal.addEventListener('slidechanged', Display._onSlideChanged);
    Display.setItemTransition(doItemTransitions && isDisplay);
    displayWatcher.setInitialised(true);
  },
  /**
   * Reinitialise Reveal
   */
  reinit: function () {
    Reveal.sync();
    // Python expects to be on first page after reinit
    Reveal.slide(0);
  },
  /**
   * Enable/Disable item transitions
   */
  setItemTransition: function (enable) {
    Display._doItemTransitions = enable;
    var body = $("body")[0];
    if (enable) {
      body.classList.add("transition");
      Reveal.configure({"backgroundTransition": "fade", "transitionSpeed": "default"});
    } else {
      body.classList.remove("transition");
      Reveal.configure({"backgroundTransition": "none"});
    }
  },
  /**
   * Clear the current list of slides
  */
  clearSlides: function () {
    Display._slidesContainer.innerHTML = "";
    Display._clearSlidesList();
  },
  /**
   * Clear the current list of slides
  */
  _clearSlidesList: function () {
    Display._footerContainer.innerHTML = "";
    Display._slides = {};
  },
  /**
   * Add new item/slides, replacing the old one
   * Clears current list of slides but allows time for a transition animation
   * Items are ordered newest to oldest in the slides container
   * @param {element} new_slides - New slides to display
   * @param {element} is_text - Used to decide if the theme main area constraints should apply
  */
  replaceSlides: function (new_slides, is_text=false) {
    if (Display._doItemTransitions) {
      new_slides.setAttribute('data-transition', "fade");
      new_slides.setAttribute('data-transition-speed', "default");
    }
    new_slides.classList.add("future");
    Display.applyTheme(new_slides, is_text);
    Display._slidesContainer.prepend(new_slides);
    var currentSlide = Reveal.getIndices();
    if (Display._doItemTransitions && Display._slidesContainer.children.length >= 2 && !Display._skipNextTransition) {
      // Set the slide one section ahead so we'll stay on the old slide after reinit
      Reveal.slide(1, currentSlide.v);
      Display.reinit();
      // Timeout to allow time to transition before deleting the old slides
      setTimeout (Display._removeLastSection, 5000);
    } else {
      Reveal.slide(0, currentSlide.v);
      Reveal.sync();
      Display._removeLastSection();
      Display._skipNextTransition = false;
    }
  },
  /**
   * Removes the last slides item if there are more than one
  */
  _removeLastSection: function () {
    if (Display._slidesContainer.children.length > 1) {
      Display._slidesContainer.lastChild.remove();
    }
  },
  /**
   * Checks if the present slide content fits within the slide
  */
  doesContentFit: function () {
    var currSlide = $("section.text-slides");
    if (currSlide.length === 0) {
      currSlide = Display._footerContainer;
    } else {
      currSlide = currSlide[0];
    }
    return currSlide.clientHeight >= currSlide.scrollHeight;
  },
  /**
   * Generate the OpenLP startup splashscreen
   * @param {string} bg_color - The background color
   * @param {string} image - Path to the splash image
   */
  setStartupSplashScreen: function(bg_color, image) {
    Display._clearSlidesList();
    var section = document.createElement("section");
    section.setAttribute("id", 0);
    section.setAttribute("data-background", bg_color);
    section.setAttribute("style", "height: 100%; width: 100%;");
    var img = document.createElement('img');
    img.src = Display._getFileUrl(image);
    img.setAttribute("style", "position: absolute; top: 0; bottom: 0; left: 0; right: 0; margin: auto; max-height: 100%; max-width: 100%");
    section.appendChild(img);
    Display._slides['0'] = 0;
    Display.replaceSlides(section);
  },
  /**
   * Set fullscreen image from path
   * @param {string} bg_color - The background color
   * @param {string} image - Path to the image
   */
  setFullscreenImage: function(bg_color, image) {
    Display.clearSlides();
    var section = document.createElement("section");
    section.setAttribute("id", 0);
    section.setAttribute("data-background", bg_color);
    section.setAttribute("style", "height: 100%; width: 100%;");
    var img = document.createElement('img');
    img.src = image;
    img.setAttribute("style", "height: 100%; width: 100%");
    section.appendChild(img);
    Display._slides['0'] = 0;
    Display.replaceSlides(section);
  },
  /**
   * Set fullscreen image from base64 data
   * @param {string} bg_color - The background color
   * @param {string} image_data - base64 encoded image data
   */
  setFullscreenImageFromData: function(bg_color, image_data) {
    Display.clearSlides();
    var section = document.createElement("section");
    section.setAttribute("id", 0);
    section.setAttribute("data-background", bg_color);
    section.setAttribute("style", "height: 100%; width: 100%;");
    var img = document.createElement('img');
    img.src = 'data:image/jpeg;base64,' + image_data;
    img.setAttribute("style", "height: 100%; width: 100%");
    section.appendChild(img);
    Display._slidesContainer.appendChild(section);
    Display._slides['0'] = 0;
    Display.reinit();
  },
  /**
   * 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) {
      Display.addAlertToQueue(text, settings);
    }
    else {
      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: _fixFontName(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;
      alertText.classList.add('scrolling');
      alertText.classList.replace("hide", "show");
      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();
    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 () {
    if (Display._alerts.length > 0) {
      var alertObject = Display._alerts.shift();
      Display._alertState = AlertState.DisplayingFromQueue;
      Display.showAlert(alertObject.text, alertObject.settings);
    }
    else {
      // For the tests
      return null;
    }
  },
  /**
   * Create a text slide.
   * @param {string} verse - The verse number, e.g. "v1"
   * @param {string} text - The HTML for the verse, e.g. "line1<br>line2"
   */
  _createTextSlide: function (verse, text) {
    var slide;
    var html = _prepareText(text);
    slide = document.createElement("section");
    slide.setAttribute("id", verse);
    // The "future" class is used internally by reveal, it's used here to hide newly added slides
    slide.classList.add("future");
    slide.innerHTML = html;
    return slide;
  },

  _onSlideChanged: function(event) {
    Display._footerContainer.querySelectorAll('.footer-item')
      .forEach(footerItem => footerItem.classList.remove('active'));
    var currentSlideNth = parseInt(event.currentSlide.getAttribute('data-slide'));
    var newActiveFooter = Display._footerContainer.querySelector('.footer-item[data-slide="' + currentSlideNth + '"]');

    if (newActiveFooter) {
      newActiveFooter.classList.add('active');
    }
  },

  /**
   * Set text slides.
   * @param {Object[]} slides - A list of slides to add as JS objects: {"verse": "v1", "text": "line 1\nline2"}
   */
  setTextSlides: function (slides) {
    Display._clearSlidesList();
    var slide_html;
    var parentSection = document.createElement("section");
    parentSection.classList = "text-slides";
    slides.forEach(function (slide, index) {
      slide_html = Display._createTextSlide(slide.verse, slide.text);
      slide_html.setAttribute('data-slide', index);
      parentSection.appendChild(slide_html);
      Display._slides[slide.verse] = parentSection.children.length - 1;
      if (slide.footer) {
        var footerSlide = document.createElement('div');
        footerSlide.classList.add('footer-item');
        footerSlide.setAttribute('data-slide', index);
        var currentSlide = Reveal.getIndices();
        if (currentSlide) {
          currentSlide = currentSlide.v;
        } else {
          currentSlide = 0;
        }
        if (index == currentSlide) {
          footerSlide.classList.add('active');
        }
        footerSlide.innerHTML = slide.footer;
        Display._footerContainer.append(footerSlide);
      }
    });
    Display.replaceSlides(parentSection, true);
  },
  /**
   * Set a single text slide. This changes the slide with no transition.
   * Prevents the need to reapply the theme if only changing content.
   * @param String slide - Text to put on the slide
   */
  setTextSlide: function (text) {
    if (Display._slides.hasOwnProperty("test-slide") && Object.keys(Display._slides).length === 1) {
      var slide = $("#" + "test-slide")[0];
      var html = _prepareText(text);
      if (slide.innerHTML != html) {
        slide.innerHTML = html;
      }
      if (!Display._themeApplied) {
        Display.applyTheme(slide.parent);
      }
    } else {
      Display._clearSlidesList();
      var slide_html;
      var parentSection = document.createElement("section");
      parentSection.classList = "text-slides";
      slide_html = Display._createTextSlide("test-slide", text);
      slide_html.setAttribute('data-slide', 0);
      parentSection.appendChild(slide_html);
      Display._slides["test-slide"] = 0;
      Display.applyTheme(parentSection);
      Display._slidesContainer.innerHTML = "";
      Display._slidesContainer.prepend(parentSection);
      Display.reinit();
    }
  },
  /**
   * Set image slides
   * @param {Object[]} slides - A list of images to add as JS objects [{"path": "url/to/file"}]
   */
  setImageSlides: function (slides) {
    Display._clearSlidesList();
    var parentSection = document.createElement("section");
    slides.forEach(function (slide, index) {
      var section = document.createElement("section");
      section.setAttribute("id", index);
      section.setAttribute("style", "height: 100%; width: 100%;");
      var img = document.createElement('img');
      img.src = Display._getFileUrl(slide.path);
      img.setAttribute("style", "width: 100%; height: 100%; margin: 0; object-fit: contain;");
      img.setAttribute('data-slide', index);
      section.appendChild(img);
      parentSection.appendChild(section);
      Display._slides[index.toString()] = index;
    });
    Display.replaceSlides(parentSection);
  },
  /**
   * Set a video
   * @param {Object} video - The video to show as a JS object: {"path": "url/to/file"}
   */
  setVideo: function (video) {
    Display._clearSlidesList();
    var section = document.createElement("section");
    section.setAttribute("data-background", "#000");
    var videoElement = document.createElement("video");
    videoElement.src = video.path;
    videoElement.preload = "auto";
    videoElement.setAttribute("id", "video");
    videoElement.setAttribute("style", "height: 100%; width: 100%;");
    videoElement.setAttribute('data-slide', 0);
    videoElement.autoplay = false;
    // All the update methods below are Python functions, hence not camelCase
    videoElement.addEventListener("durationchange", function (event) {
      mediaWatcher.update_duration(event.target.duration);
    });
    videoElement.addEventListener("timeupdate", function (event) {
      mediaWatcher.update_progress(event.target.currentTime);
    });
    videoElement.addEventListener("volumeupdate", function (event) {
      mediaWatcher.update_volume(event.target.volume);
    });
    videoElement.addEventListener("ratechange", function (event) {
      mediaWatcher.update_playback_rate(event.target.playbackRate);
    });
    videoElement.addEventListener("ended", function (event) {
      mediaWatcher.has_ended(event.target.ended);
    });
    videoElement.addEventListener("muted", function (event) {
      mediaWatcher.has_muted(event.target.muted);
    });
    section.appendChild(videoElement);
    Display.replaceSlides(section);
  },
  /**
   * Play a video
   */
  playVideo: function () {
    var videoElem = $("#video");
    if (videoElem.length == 1) {
      videoElem[0].play();
    }
  },
  /**
   * Pause a video
   */
  pauseVideo: function () {
    var videoElem = $("#video");
    if (videoElem.length == 1) {
      videoElem[0].pause();
    }
  },
  /**
   * Stop a video
   */
  stopVideo: function () {
    var videoElem = $("#video");
    if (videoElem.length == 1) {
      videoElem[0].pause();
      videoElem[0].currentTime = 0.0;
    }
  },
  /**
   * Go to a particular time in a video
   * @param seconds The position in seconds to seek to
   */
  seekVideo: function (seconds) {
    var videoElem = $("#video");
    if (videoElem.length == 1) {
      videoElem[0].currentTime = seconds;
    }
  },
  /**
   * Set the playback rate of a video
   * @param rate A Double of the rate. 1.0 => 100% speed, 0.75 => 75% speed, 1.25 => 125% speed, etc.
   */
  setPlaybackRate: function (rate) {
    var videoElem = $("#video");
    if (videoElem.length == 1) {
      videoElem[0].playbackRate = rate;
    }
  },
  /**
   * Set the volume
   * @param level The volume level from 0 to 100.
   */
  setVideoVolume: function (level) {
    var videoElem = $("#video");
    if (videoElem.length == 1) {
      videoElem[0].volume = level / 100.0;
    }
  },
  /**
   * Mute the volume
   */
  toggleVideoMute: function () {
    var videoElem = $("#video");
    if (videoElem.length == 1) {
      videoElem[0].muted = !videoElem[0].muted;
    }
  },
  /**
   * Clear the background audio playlist
   */
  clearPlaylist: function () {
    var backgroundAudoElem = $("#background-audio");
    if (backgroundAudoElem.length == 1) {
      var audio = backgroundAudoElem[0];
      /* audio.playList */
    }
  },
  /**
   * Add background audio
   * @param files The list of files as objects in an array
   */
  addBackgroundAudio: function (files) {
  },
  /**
   * Go to a slide.
   * @param slide The slide number or name, e.g. "v1", 0
   */
  goToSlide: function (slide) {
    if (Display._slides.hasOwnProperty(slide)) {
      Reveal.slide(0, Display._slides[slide]);
    }
    else {
      Reveal.slide(0, slide);
    }
  },
  /**
   * Go to the next slide in the list
  */
  next: Reveal.nextFragment,
  /**
   * Go to the previous slide in the list
  */
  prev: Reveal.prevFragment,
  /**
   * Blank the screen
  */
  toBlack: function () {
    return new Promise((resolve, reject) => {
      /* 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;
          resolve();
        });
      });
    });
  },
  /**
   * Hide all but theme background
  */
  toTheme: function () {
    return new Promise((resolve, reject) => {
      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;
      Display._footerContainer.style.opacity = 0;
      if (Reveal.isPaused()) {
        Reveal.togglePause();
      }
      Display._reenableGlobalTransitions(function() {
        resolve();
      });
    });
  },
  /**
   * Hide everything (CAUTION: Causes a invisible mouse barrier)
  */
  toTransparent: function () {
    return new Promise((resolve, reject) => {
      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();
          resolve();
        });
      }
    });
  },
  /**
   * Show the screen
  */
  show: function () {
    return new Promise((resolve, reject) => {
      var documentBody = $("body")[0];
      /*
        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;
        resolve();
      });
    });
  },

  _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
   */
  calculateLineCount: function (fontSize) {
    var p = $(".slides > section > section > p");
    if (p.length == 0) {
      Display.addSlide("v1", "Arky arky");
      p = $(".slides > section > section > p");
    }
    p = p[0];
    p.style.fontSize = "" + fontSize + "pt";
    var d = $(".slides > section")[0];
    var lh = parseFloat(_getStyle(p, "line-height"));
    var dh = parseFloat(_getStyle(d, "height"));
    return Math.floor(dh / lh);
  },
  /**
   * Prepare the theme for the next item to be added
   * @param theme The theme to be used
   */
  setTheme: function (theme) {
    if (Display._theme != theme) {
      Display._themeApplied = false;
      Display._theme = theme;
    }
  },
  /**
   * Set background image, replaced when theme is updated/applied
   * @param image_path Image path
   */
  setBackgroundImage: function (image_path) {
    var targetElement = $(".slides > section")[0];
    targetElement.setAttribute("data-background", "url('" + image_path + "')");
    targetElement.setAttribute("data-background-size", "cover");
    Reveal.sync();
  },
  /**
   * Reset/reapply the theme
   */
  resetTheme: function () {
    var targetElement = $(".slides > section")[0];
    if (!targetElement) {
      console.warn("Couldn't reset theme: No slides exist");
      return;
    }
    Display.applyTheme(targetElement, targetElement.classList.contains("text-slides"));
    Reveal.sync();
  },
  /**
   * Apply the theme to the provided element
   * @param targetElement The target element to apply the theme (expected to be a <section> in the slides container)
   * @param is_text Used to decide if the main area constraints should be applied
   */
  applyTheme: function (targetElement, is_text=true) {
    Display._themeApplied = true;
    if (!Display._theme) {
      return;
    }
    // Set slide transitions
    var new_transition_type = "none",
        new_transition_speed = "default";
    if (!!Display._theme.display_slide_transition && Display._doTransitions) {
      switch (Display._theme.display_slide_transition_type) {
        case TransitionType.Fade:
          new_transition_type = "fade";
          break;
        case TransitionType.Slide:
          new_transition_type = "slide";
          break;
        case TransitionType.Convex:
          new_transition_type = "convex";
          break;
        case TransitionType.Concave:
          new_transition_type = "concave";
          break;
        case TransitionType.Zoom:
          new_transition_type = "zoom";
          break;
        default:
          new_transition_type = "fade";
      }
      switch (Display._theme.display_slide_transition_speed) {
        case TransitionSpeed.Normal:
          new_transition_speed = "default";
          break;
        case TransitionSpeed.Fast:
          new_transition_speed = "fast";
          break;
        case TransitionSpeed.Slow:
          new_transition_speed = "slow";
          break;
        default:
          new_transition_speed = "default";
      }
      switch (Display._theme.display_slide_transition_direction) {
        case TransitionDirection.Vertical:
          new_transition_type += "-vertical";
          break;
        case TransitionDirection.Horizontal:
        default:
          new_transition_type += "-horizontal";
      }
      if (Display._theme.display_slide_transition_reverse) {
        new_transition_type += "-reverse";
      }
    }
    var slides = targetElement.children;
    for (var i = 0; i < slides.length; i++) {
      slides[i].setAttribute("data-transition", new_transition_type);
      slides[i].setAttribute("data-transition-speed", new_transition_speed);
    }
    // Set the background
    var backgroundContent = "";
    var backgroundHtml = "";
    switch (Display._theme.background_type) {
      case BackgroundType.Transparent:
        backgroundContent = "transparent";
        break;
      case BackgroundType.Solid:
        backgroundContent = Display._theme.background_color;
        break;
      case BackgroundType.Gradient:
        switch (Display._theme.background_direction) {
          case GradientType.Horizontal:
            backgroundContent = _buildLinearGradient("to right",
                                                                 Display._theme.background_start_color,
                                                                 Display._theme.background_end_color);
            break;
          case GradientType.Vertical:
            backgroundContent = _buildLinearGradient("to bottom",
                                                                 Display._theme.background_start_color,
                                                                 Display._theme.background_end_color);
            break;
          case GradientType.LeftTop:
            backgroundContent = _buildLinearGradient("to right bottom",
                                                                 Display._theme.background_start_color,
                                                                 Display._theme.background_end_color);
            break;
          case GradientType.LeftBottom:
            backgroundContent = _buildLinearGradient("to top right",
                                                                 Display._theme.background_start_color,
                                                                 Display._theme.background_end_color);
            break;
          case GradientType.Circular:
            backgroundContent = _buildRadialGradient(window.innerWidth / 2, Display._theme.background_start_color,
                                                                 Display._theme.background_end_color);
            break;
          default:
            backgroundContent = "#000";
        }
        break;
      case BackgroundType.Image:
        backgroundContent = "url('" + Display._getFileUrl(Display._theme.background_filename) + "')";
        break;
      case BackgroundType.Video:
        // never actually used since background type is overridden from video to transparent in window.py
        backgroundContent = Display._theme.background_border_color;
        backgroundHtml = "<video loop autoplay muted><source src='" + Display._getFileUrl(Display._theme.background_filename) + "'></video>";
        break;
      default:
        backgroundContent = "#000";
    }
    targetElement.style.cssText = "";
    targetElement.setAttribute("data-background", backgroundContent);
    targetElement.setAttribute("data-background-size", "cover");

    // set up the main area
    if (!is_text) {
      // only transition and background for non text slides
      return;
    }
    mainStyle = {};
    // These need to be fixed, in the Python they use a width passed in as a parameter
    mainStyle.width = Display._theme.font_main_width + "px";
    mainStyle.height = Display._theme.font_main_height + "px";
    mainStyle["margin-top"] = "" + Display._theme.font_main_y + "px";
    mainStyle.left = "" + Display._theme.font_main_x + "px";
    mainStyle.color = Display._theme.font_main_color;
    mainStyle["font-family"] = Display._theme.font_main_name;
    mainStyle["font-size"] = "" + Display._theme.font_main_size + "pt";
    mainStyle["font-style"] = !!Display._theme.font_main_italics ? "italic" : "";
    mainStyle["font-weight"] = !!Display._theme.font_main_bold ? "bold" : "";
    mainStyle["line-height"] = "" + (100 + Display._theme.font_main_line_adjustment) + "%";
    mainStyle["letter-spacing"] = "" + (Display._theme.font_main_letter_adjustment) + 'px';
    // Using text-align-last because there is a <br> seperating each line
    switch (Display._theme.display_horizontal_align) {
      case HorizontalAlign.Justify:
        mainStyle["text-align"] = "justify";
        mainStyle["text-align-last"] = "justify";
        break;
      case HorizontalAlign.Center:
        mainStyle["text-align"] = "center";
        mainStyle["text-align-last"] = "center";
        break;
      case HorizontalAlign.Left:
        mainStyle["text-align"] = "left";
        mainStyle["text-align-last"] = "left";
        break;
      case HorizontalAlign.Right:
        mainStyle["text-align"] = "right";
        mainStyle["text-align-last"] = "right";
        break;
      default:
        mainStyle["text-align"] = "center";
        mainStyle["text-align-last"] = "center";
    }
    switch (Display._theme.display_vertical_align) {
      case VerticalAlign.Middle:
        mainStyle["justify-content"] = "center";
        break;
      case VerticalAlign.Top:
        mainStyle["justify-content"] = "flex-start";
        break;
      case VerticalAlign.Bottom:
        mainStyle["justify-content"] = "flex-end";
        // This gets around the webkit scroll height bug
        mainStyle["padding-bottom"] = "" + (Display._theme.font_main_size / 8) + "px";
        break;
      default:
        mainStyle["justify-content"] = "center";
    }
    /**
     * This section draws the font outline. Previously we used the proprietary -webkit-text-stroke property
     * but it draws the outline INSIDE the text, instead of OUTSIDE, so we had to go back to the old way
     * of using multiple text-shadow rules to fake an outline.
     */
    if (!!Display._theme.font_main_outline && Display._theme.hasOwnProperty('font_main_shadow_size') && !!Display._theme.font_main_shadow) {
      let outlineShadows = _buildTextOutline(Display._theme.font_main_outline_size, Display._theme.font_main_outline_color);
      let textShadow = _buildTextShadow(Display._theme.font_main_shadow_size, Display._theme.font_main_outline_size, Display._theme.font_main_shadow_color);
      mainStyle["text-shadow"] = outlineShadows.join(", ") + ", " + textShadow;
    }
    else if (!!Display._theme.font_main_outline) {
      let outlineShadows = _buildTextOutline(Display._theme.font_main_outline_size, Display._theme.font_main_outline_color);
      mainStyle["text-shadow"] = outlineShadows.join(", ");
    }
    else if (Display._theme.hasOwnProperty('font_main_shadow_size') && !!Display._theme.font_main_shadow) {
      mainStyle["text-shadow"] = _buildTextShadow(Display._theme.font_main_shadow_size, 0, Display._theme.font_main_shadow_color);
    }
    targetElement.style.cssText = "";
    for (var mainKey in mainStyle) {
      if (mainStyle.hasOwnProperty(mainKey)) {
        targetElement.style.setProperty(mainKey, mainStyle[mainKey]);
      }
    }
    // Set up the footer
    footerStyle = {
      "text-align": "left"
    };
    footerStyle.position = "absolute";
    footerStyle.left = "" + Display._theme.font_footer_x + "px";
    footerStyle.top = "" + Display._theme.font_footer_y + "px";
    footerStyle.width = "" + Display._theme.font_footer_width + "px";
    footerStyle.height = "" + Display._theme.font_footer_height + "px";
    footerStyle.color = Display._theme.font_footer_color;
    footerStyle["font-family"] = Display._theme.font_footer_name;
    footerStyle["font-size"] = "" + Display._theme.font_footer_size + "pt";
    footerStyle["font-style"] = !!Display._theme.font_footer_italics ? "italic" : "";
    footerStyle["font-weight"] = !!Display._theme.font_footer_bold ? "bold" : "";
    footerStyle["line-height"] = "" + (100 + Display._theme.font_footer_line_adjustment) + "%";
    footerStyle["letter-spacing"] = "" + (Display._theme.font_footer_letter_adjustment) + 'px';
    footerStyle["white-space"] = Display._theme.font_footer_wrap ? "normal" : "nowrap";
    for (var footerKey in footerStyle) {
      if (footerStyle.hasOwnProperty(footerKey)) {
        Display._footerContainer.style.setProperty(footerKey, footerStyle[footerKey]);
      }
    }
  },
  /**
   * Called whenever openlp wants to finish completely with the current text/image slides
   * because a different window (eg presentation or vlc) is going to be displaying the next item
   * and we don't want any flashbacks to the current slide contents
   */
  finishWithCurrentItem: function () {
    Display.setTextSlide('');
    var documentBody = $("body")[0];
    documentBody.style.opacity = 1;
    Display._skipNextTransition = true;
    displayWatcher.pleaseRepaint();
  },
  /**
   * Return the video types supported by the video tag
   */
  getVideoTypes: function () {
    var videoElement = document.createElement('video');
    var videoTypes = [];
    if (videoElement.canPlayType('video/mp4; codecs="mp4v.20.8"') == "probably" ||
        videoElement.canPlayType('video/mp4; codecs="avc1.42E01E"') == "pobably" ||
        videoElement.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"') == "probably") {
      videoTypes.push(['video/mp4', '*.mp4']);
    }
    if (videoElement.canPlayType('video/ogg; codecs="theora"') == "probably") {
      videoTypes.push(['video/ogg', '*.ogv']);
    }
    if (videoElement.canPlayType('video/webm; codecs="vp8, vorbis"') == "probably") {
      videoTypes.push(['video/webm', '*.webm']);
    }
    return videoTypes;
  },
  /**
   * Sets the scale of the page - used to make preview widgets scale
   */
  setScale: function(scale) {
    document.body.style.zoom = scale+"%";
  },
  /**
   * In order to check if a font exists, we need a container to do
   * calculations on. This method creates that container and caches
   * some width values so that we don't have to do this step every
   * time we check if a font exists.
   */
  _prepareFontContainer: function() {
    Display._fontContainer = document.createElement("span");
    Display._fontContainer.id = "does-font-exist";
    Display._fontContainer.innerHTML = Array(100).join("wi");
    Display._fontContainer.style.cssText = [
      "position: absolute",
      "width: auto",
      "font-size: 128px",
      "left: -999999px"
    ].join(" !important;");
    document.body.appendChild(Display._fontContainer);
  },
  /**
   * Prepare the slide number (slide x/y) for insertion into the Reveal footer
   * This is a callback function which Reveal calls to get the values
   * Fixes https://gitlab.com/openlp/openlp/-/issues/942
   */
  setFooterSlideNumbers: function (slide) {
    let value = ['', '', ''];
    // Reveal does call this function passing undefined
    if (typeof slide === 'undefined') {
      return value;
    }
    value[0] = Reveal.getSlidePastCount(slide) + 1;
    value[1] = '/';
    value[2] = Object.keys(Display._slides).length;
    return value;
  },
  /**
   * Translates file:// protocol URLs to openlp-library://local-file/ scheme
   */
  _getFileUrl: function(url) {
    if (url && (url.indexOf('file://') === 0)) {
      return url.replace('file://', 'openlp-library://local-file/');
    }
    return url;
  },
};

Display._handleNativeCall = (action, ...values) => {
  if (Display[action]) {
    return Display[action](...values);
  }
};

initCommunicationBridge();
communicationBridge.setDisplayTarget(Display);
