diff --git a/assets/clapprio/timelens.css b/assets/clapprio/timelens.css
new file mode 100644
index 00000000..267cab6f
--- /dev/null
+++ b/assets/clapprio/timelens.css
@@ -0,0 +1,113 @@
+/* Common styles */
+
+.timelens {
+ position: relative;
+}
+
+.timelens img {
+ width: 100%;
+ height: 100%;
+ display: block;
+
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -o-user-select: none;
+ user-select: none;
+
+ position: relative;
+}
+
+.timelens-thumbnail {
+ background: black;
+ border: solid rgba(28, 28, 28, 0.9) 3px !important;
+ border-radius: 3px;
+ background-clip: content-box;
+ width: 0px;
+ height: 0px;
+ position: absolute;
+ top: 0;
+ z-index: -1;
+ opacity: 0;
+ transition: opacity 0.2s, z-index 0.2s;
+ transform: translateY(-100%) translateY(-7px);
+}
+
+.timelens-time {
+ font-size: 15px !important;
+ font-family: Arial, sans-serif !important;
+ color: #eee;
+ background: rgba(28, 28, 28, 0.9);
+ position: absolute;
+ left: 50%;
+ bottom: 0;
+ transform: translateX(-50%);
+ display: inline-block !important;
+ padding: 0 10px !important;
+ border-radius: 2px 2px 0 0;
+ height: 22px;
+ vertical-align: middle !important;
+ line-height: 25px !important;
+}
+
+.timelens img:hover + .timelens-thumbnail {
+ z-index: 1;
+ opacity: 1;
+}
+
+.timelens-marker {
+ width: 0px;
+ height: 0px;
+ border-top: 15px solid #eee !important;
+ border-left: 10px solid transparent !important;
+ border-right: 10px solid transparent !important;
+ position: absolute;
+ top: -20px;
+ z-index: 999;
+ transform: translateX(-50%);
+}
+
+.timelens-marker-border {
+ width: 0px;
+ height: 0px;
+ border-top: 23px solid rgba(28, 28, 28, 0.9) !important;
+ border-left: 15px solid transparent !important;
+ border-right: 15px solid transparent !important;
+ position: absolute;
+ top: -10px;
+ z-index: 999;
+ transform: translateX(-50%);
+}
+
+/* MediaElement.js */
+
+.mejs__time-slider {
+ height: 40px;
+ margin-top: -10px;
+}
+
+.mejs__time-buffering,
+.mejs__time-loaded,
+.mejs__time-current,
+.mejs__time-hovered,
+.mejs__time-handle,
+.mejs__time-float {
+ display: none !important;
+}
+
+/* Clappr */
+
+.media-control .bar-background {
+ height: 40px !important;
+ margin-top: -40px;
+}
+
+.media-control .seek-time,
+.media-control .bar-scrubber,
+.media-control .bar-hover {
+ display: none !important;
+}
+
+.media-control-hide {
+ bottom: -40px !important;
+}
diff --git a/assets/clapprio/timelens.js b/assets/clapprio/timelens.js
new file mode 100644
index 00000000..ed714c4e
--- /dev/null
+++ b/assets/clapprio/timelens.js
@@ -0,0 +1,268 @@
+/* Common functionality */
+
+function timelens(container, options) {
+ // Load VTT file asynchronously, then continue with the initialization.
+ let vtt_url;
+ if (options.thumbnails) {
+ vtt_url = options.thumbnails;
+ }
+
+ const request = new XMLHttpRequest();
+ request.open("GET", vtt_url, true);
+ request.send(null);
+ request.onreadystatechange = function() {
+ if (request.readyState === 4 && request.status === 200) {
+ const type = request.getResponseHeader("Content-Type");
+ if (type.indexOf("text") !== 1) {
+ timelens2(container, request.responseText, options);
+ }
+ }
+ };
+}
+
+// Actually initialize Timelens.
+function timelens2(container, vtt, options) {
+ const thumbnails = parseVTT(vtt);
+ const duration = thumbnails[thumbnails.length - 1].to;
+
+ // Use querySelector if a selector string is specified.
+ if (typeof container == "string")
+ container = document.querySelector(container);
+
+ // This will be our main .timelens div, which will contain all new elements.
+ if (container.className != "") {
+ container.className += " ";
+ }
+ container.className += "timelens";
+
+ // Create div which contains the preview thumbnails.
+ const thumbnail = document.createElement("div");
+ thumbnail.className = "timelens-thumbnail";
+
+ // Create div which contains the thumbnail time.
+ const time = document.createElement("div");
+ time.className = "timelens-time";
+
+ // Create .timeline img, which displays the visual timeline.
+ const timeline = document.createElement("img");
+ timeline.src = options.timeline;
+ // Prevent the timeline image to be dragged
+ timeline.setAttribute("draggable", "false");
+
+ // Create .marker div, which is used to display the current position.
+ if (options.position) {
+ var marker = document.createElement("div");
+ marker.className = "timelens-marker-border";
+ container.appendChild(marker);
+
+ var markerInner = document.createElement("div");
+ markerInner.className = "timelens-marker";
+ marker.appendChild(markerInner);
+ }
+
+ // Assemble everything together.
+ container.appendChild(timeline);
+ container.appendChild(thumbnail);
+ thumbnail.appendChild(time);
+
+ // When clicking the timeline, seek to the respective position.
+ if (options.seek) {
+ timeline.onclick = function(event) {
+ const progress = progressAtMouse(event, timeline);
+ options.seek(progress * duration);
+ };
+ }
+
+ timeline.onmousemove = function(event) {
+ // Calculate click position in seconds.
+ const progress = progressAtMouse(event, timeline);
+ const seconds = progress * duration;
+ const x = progress * timeline.offsetWidth;
+
+ const thumbnail_dir = options.thumbnails.substring(
+ 0,
+ options.thumbnails.lastIndexOf("/") + 1
+ );
+
+ // Find the first entry in `thumbnails` which contains the current position.
+ let active_thumbnail = null;
+ for (let t of thumbnails) {
+ if (seconds >= t.from && seconds <= t.to) {
+ active_thumbnail = t;
+ break;
+ }
+ }
+
+ // Set respective background image.
+ thumbnail.style["background-image"] =
+ "url(" + thumbnail_dir + active_thumbnail.file + ")";
+ // Move background to the correct location.
+ thumbnail.style["background-position"] =
+ -active_thumbnail.x + "px " + -active_thumbnail.y + "px";
+
+ // Set thumbnail div to correct size.
+ thumbnail.style.width = active_thumbnail.w + "px";
+ thumbnail.style.height = active_thumbnail.h + "px";
+
+ // Move thumbnail div to the correct position.
+ thumbnail.style.marginLeft =
+ Math.min(
+ Math.max(0, x - thumbnail.offsetWidth / 2),
+ timeline.offsetWidth - thumbnail.offsetWidth
+ ) + "px";
+
+ time.innerHTML = to_timestamp(seconds);
+ };
+
+ if (options.position) {
+ setInterval(function() {
+ marker.style.marginLeft =
+ (options.position() / duration) * timeline.offsetWidth + "px";
+ }, 1);
+ }
+}
+
+// Convert a WebVTT timestamp (which has the format [HH:]MM:SS.mmm) to seconds.
+function from_timestamp(timestamp) {
+ const matches = timestamp.match(/(.*):(.*)\.(.*)/);
+
+ const minutes = parseInt(matches[1]);
+ const seconds = parseInt(matches[2]);
+ const mseconds = parseInt(matches[3]);
+
+ const seconds_total = mseconds / 1000 + seconds + 60 * minutes;
+
+ return seconds_total;
+}
+
+// Convert a position in seconds to a [H:]MM:SS timestamp.
+function to_timestamp(seconds_total) {
+ const hours = Math.floor(seconds_total / 60 / 60);
+ const minutes = Math.floor(seconds_total / 60 - hours * 60);
+ const seconds = Math.floor(seconds_total - 60 * minutes - hours * 60 * 60);
+
+ const timestamp = minutes + ":" + pad(seconds, 2);
+
+ if (hours > 0) {
+ return hours + ":" + pad(timestamp, 5);
+ } else {
+ return timestamp;
+ }
+}
+
+// How far is the mouse into the timeline, in a range from 0 to 1?
+function progressAtMouse(event, timeline) {
+ const x = event.offsetX ? event.offsetX : event.pageX - timeline.offsetLeft;
+ return x / timeline.offsetWidth;
+}
+
+// Parse a VTT file pointing to JPEG files using media fragment notation.
+function parseVTT(vtt) {
+ let from = 0;
+ let to = 0;
+
+ let thumbnails = [];
+
+ for (let line of vtt.split("\n")) {
+ if (/-->/.test(line)) {
+ // Parse a "cue timings" part.
+ const matches = line.match(/(.*) --> (.*)/);
+
+ from = from_timestamp(matches[1]);
+ to = from_timestamp(matches[2]);
+ } else if (/jpg/.test(line)) {
+ // Parse a "cue payload" part.
+ const matches = line.match(/(.*)\?xywh=(.*),(.*),(.*),(.*)/);
+
+ thumbnails.push({
+ from: from,
+ to: to,
+ file: matches[1],
+ x: matches[2],
+ y: matches[3],
+ w: matches[4],
+ h: matches[5]
+ });
+ }
+ }
+
+ return thumbnails;
+}
+
+function pad(num, size) {
+ return ("000000000" + num).substr(-size);
+}
+
+/* MediaElement.js */
+
+if (typeof MediaElementPlayer !== "undefined") {
+ Object.assign(MediaElementPlayer.prototype, {
+ buildtimelens(player, controls, layers, media) {
+ const t = this;
+
+ // Get the timeline from the video's "timeline" attribute.
+ const vid = media.querySelector("video");
+ const timeline = vid.dataset.timeline;
+
+ // Get the thumbnails VTT from a "thumbnails" track.
+ const thumbnailsTrack = vid.querySelector(
+ 'track[label="thumbnails"]'
+ );
+ const thumbnails = thumbnailsTrack.src;
+
+ const slider = controls.querySelector(
+ "." + t.options.classPrefix + "time-slider"
+ );
+
+ // Initialize the Timelens interface.
+ timelens(slider, {
+ timeline: timeline,
+ thumbnails: thumbnails,
+ position: function() {
+ return player.currentTime;
+ }
+ });
+ }
+ });
+}
+
+/* Clappr */
+
+if (typeof Clappr !== "undefined") {
+ class TimelensPlugin extends Clappr.UICorePlugin {
+ get name() {
+ return "timelens";
+ }
+
+ constructor(core) {
+ super(core);
+ }
+
+ bindEvents() {
+ this.listenTo(
+ this.core.mediaControl,
+ Clappr.Events.MEDIACONTROL_RENDERED,
+ this._init
+ );
+ }
+
+ _init() {
+ const bar = this.core.mediaControl.el.querySelector(
+ ".bar-background"
+ );
+
+ let t = this;
+
+ // Initialize the Timelens interface.
+ timelens(bar, {
+ timeline: this.core.options.timelens.timeline,
+ thumbnails: this.core.options.timelens.thumbnails,
+ position: function() {
+ return t.core.containers[0].getCurrentTime();
+ }
+ });
+ }
+ }
+
+ window.TimelensPlugin = TimelensPlugin;
+}
diff --git a/assets/js/lustiges-script.js b/assets/js/lustiges-script.js
index 22c4e04a..c3cf8808 100644
--- a/assets/js/lustiges-script.js
+++ b/assets/js/lustiges-script.js
@@ -34,9 +34,17 @@ $(function() {
var $relivePlayer = $('body.relive-player .video-wrap');
if($relivePlayer.length > 0) {
+ var plugins = [PlaybackRatePlugin];
var sprites = [];
- if($relivePlayer.data("sprites")) {
+ if ($relivePlayer.data("timelens-timeline")) {
+ plugins.push(TimelensPlugin);
+
+ // the timelens CSS always causes an extra bar to be drawn even if we're not using it. Hotfix:
+ $('head').append('');
+ } else if($relivePlayer.data("sprites")) {
+ plugins.push(ClapprThumbnailsPlugin);
+
sprites = ClapprThumbnailsPlugin.buildSpriteConfig(
$relivePlayer.data("sprites"),
$relivePlayer.data("sprites-n"),
@@ -49,7 +57,7 @@ $(function() {
var player = new Clappr.Player({
baseUrl: 'assets/clapprio/',
plugins: {
- core: [ClapprThumbnailsPlugin, PlaybackRatePlugin]
+ core: plugins
},
source: $relivePlayer.data('m3u8'),
@@ -61,6 +69,10 @@ $(function() {
spotlightHeight: 84,
thumbs: sprites
},
+ timelens: {
+ timeline: $relivePlayer.data("timelens-timeline"),
+ thumbnails: $relivePlayer.data("timelens-thumbs")
+ },
events: {
onReady: function() {
var playback = player.core.getCurrentContainer().playback;
diff --git a/template/assemblies/player/relive.phtml b/template/assemblies/player/relive.phtml
index 361e1bbc..064bf53d 100644
--- a/template/assemblies/player/relive.phtml
+++ b/template/assemblies/player/relive.phtml
@@ -10,5 +10,9 @@
data-sprites-cols="= h($talk['sprites']['cols']) ?>"
data-sprites-interval="= h($talk['sprites']['interval']) ?>"
endif ?>
+ if(array_key_exists('timelens', $talk)): ?>
+ data-timelens-timeline="= h($talk['timelens']['timeline']) ?>"
+ data-timelens-thumbs="= h($talk['timelens']['thumbs']) ?>"
+ endif ?>
>
diff --git a/template/page.phtml b/template/page.phtml
index 9fb24adc..fc34c4f7 100644
--- a/template/page.phtml
+++ b/template/page.phtml
@@ -63,6 +63,7 @@
+
if(isset($subtitles) && $subtitles->isEnabled()): ?>