summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--assets/css/videojs-vtt-thumbnails.css7
-rw-r--r--assets/js/videojs-vtt-thumbnails.min.js7
-rw-r--r--src/invidious.cr89
-rw-r--r--src/invidious/videos.cr7
-rw-r--r--src/invidious/views/components/player.ecr4
-rw-r--r--src/invidious/views/components/player_sources.ecr2
-rw-r--r--src/invidious/views/licenses.ecr14
7 files changed, 126 insertions, 4 deletions
diff --git a/assets/css/videojs-vtt-thumbnails.css b/assets/css/videojs-vtt-thumbnails.css
new file mode 100644
index 00000000..3d393958
--- /dev/null
+++ b/assets/css/videojs-vtt-thumbnails.css
@@ -0,0 +1,7 @@
+/**
+ * videojs-vtt-thumbnails
+ * @version 0.0.13
+ * @copyright 2019 Chris Boustead <chris@forgemotion.com>
+ * @license MIT
+ */
+.video-js.vjs-vtt-thumbnails{display:block}.video-js .vjs-vtt-thumbnail-display{position:absolute;transition:transform .1s, opacity .2s;bottom:85%;pointer-events:none;box-shadow:0 0 7px rgba(0,0,0,0.6)}
diff --git a/assets/js/videojs-vtt-thumbnails.min.js b/assets/js/videojs-vtt-thumbnails.min.js
new file mode 100644
index 00000000..895458e8
--- /dev/null
+++ b/assets/js/videojs-vtt-thumbnails.min.js
@@ -0,0 +1,7 @@
+/**
+ * videojs-vtt-thumbnails
+ * @version 0.0.13
+ * @copyright 2019 Chris Boustead <chris@forgemotion.com>
+ * @license MIT
+ */
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("video.js")):"function"==typeof define&&define.amd?define(["video.js"],e):t.videojsVttThumbnails=e(t.videojs)}(this,function(i){"use strict";i=i&&i.hasOwnProperty("default")?i.default:i;!function(){function l(t){this.value=t}function t(i){var o,n;function a(t,e){try{var r=i[t](e),s=r.value;s instanceof l?Promise.resolve(s.value).then(function(t){a("next",t)},function(t){a("throw",t)}):u(r.done?"return":"normal",r.value)}catch(t){u("throw",t)}}function u(t,e){switch(t){case"return":o.resolve({value:e,done:!0});break;case"throw":o.reject(e);break;default:o.resolve({value:e,done:!1})}(o=o.next)?a(o.key,o.arg):n=null}this._invoke=function(s,i){return new Promise(function(t,e){var r={key:s,arg:i,resolve:t,reject:e,next:null};n?n=n.next=r:(o=n=r,a(s,i))})},"function"!=typeof i.return&&(this.return=void 0)}"function"==typeof Symbol&&Symbol.asyncIterator&&(t.prototype[Symbol.asyncIterator]=function(){return this}),t.prototype.next=function(t){return this._invoke("next",t)},t.prototype.throw=function(t){return this._invoke("throw",t)},t.prototype.return=function(t){return this._invoke("return",t)}}();var o={},t=i.registerPlugin||i.plugin,e=function(r){var s=this;this.ready(function(){var t,e;t=s,e=i.mergeOptions(o,r),t.addClass("vjs-vtt-thumbnails"),t.vttThumbnails=new n(t,e)})},n=function(){function r(t,e){return function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}(this,r),this.player=t,this.options=e,this.listenForDurationChange(),this.initializeThumbnails(),this.registeredEvents={},this}return r.prototype.src=function(t){this.resetPlugin(),this.options.src=t,this.initializeThumbnails()},r.prototype.detach=function(){this.resetPlugin()},r.prototype.resetPlugin=function(){this.thumbnailHolder&&this.thumbnailHolder.parentNode.removeChild(this.thumbnailHolder),this.progressBar&&this.progressBar.removeEventListener("mouseenter",this.registeredEvents.progressBarMouseEnter),this.progressBar&&this.progressBar.removeEventListener("mouseleave",this.registeredEvents.progressBarMouseLeave),this.progressBar&&this.progressBar.removeEventListener("mousemove",this.registeredEvents.progressBarMouseMove),delete this.registeredEvents.progressBarMouseEnter,delete this.registeredEvents.progressBarMouseLeave,delete this.registeredEvents.progressBarMouseMove,delete this.progressBar,delete this.vttData,delete this.thumbnailHolder,delete this.lastStyle},r.prototype.listenForDurationChange=function(){this.player.on("durationchange",function(){})},r.prototype.initializeThumbnails=function(){var e=this;if(this.options.src){var t=this.getBaseUrl(),r=this.getFullyQualifiedUrl(this.options.src,t);this.getVttFile(r).then(function(t){e.vttData=e.processVtt(t),e.setupThumbnailElement()})}},r.prototype.getBaseUrl=function(){return[window.location.protocol,"//",window.location.hostname,window.location.port?":"+window.location.port:"",window.location.pathname].join("").split(/([^\/]*)$/gi).shift()},r.prototype.getVttFile=function(s){var i=this;return new Promise(function(t,e){var r=new XMLHttpRequest;r.data={resolve:t},r.addEventListener("load",i.vttFileLoaded),r.open("GET",s),r.send()})},r.prototype.vttFileLoaded=function(){this.data.resolve(this.responseText)},r.prototype.setupThumbnailElement=function(t){var e=this,r=this.player.$(".vjs-mouse-display");this.progressBar=this.player.$(".vjs-progress-control");var s=document.createElement("div");s.setAttribute("class","vjs-vtt-thumbnail-display"),this.progressBar.appendChild(s),this.thumbnailHolder=s,r&&r.classList.add("vjs-hidden"),this.registeredEvents.progressBarMouseEnter=function(){return e.onBarMouseenter()},this.registeredEvents.progressBarMouseLeave=function(){return e.onBarMouseleave()},this.progressBar.addEventListener("mouseenter",this.registeredEvents.progressBarMouseEnter),this.progressBar.addEventListener("mouseleave",this.registeredEvents.progressBarMouseLeave)},r.prototype.onBarMouseenter=function(){var e=this;this.mouseMoveCallback=function(t){e.onBarMousemove(t)},this.registeredEvents.progressBarMouseMove=this.mouseMoveCallback,this.progressBar.addEventListener("mousemove",this.registeredEvents.progressBarMouseMove),this.showThumbnailHolder()},r.prototype.onBarMouseleave=function(){this.registeredEvents.progressBarMouseMove&&this.progressBar.removeEventListener("mousemove",this.registeredEvents.progressBarMouseMove),this.hideThumbnailHolder()},r.prototype.getXCoord=function(t,e){var r=t.getBoundingClientRect(),s=document.documentElement;return e-(r.left+(window.pageXOffset||s.scrollLeft||0))},r.prototype.onBarMousemove=function(t){this.updateThumbnailStyle(this.getXCoord(this.progressBar,t.clientX),this.progressBar.offsetWidth)},r.prototype.getStyleForTime=function(t){for(var e=0;e<this.vttData.length;++e){var r=this.vttData[e];if(t>=r.start&&t<r.end)return r.css}},r.prototype.showThumbnailHolder=function(){this.thumbnailHolder.style.opacity="1"},r.prototype.hideThumbnailHolder=function(){this.thumbnailHolder.style.opacity="0"},r.prototype.updateThumbnailStyle=function(t,e){var r=(1-(e-t)/e)*this.player.duration(),s=this.getStyleForTime(r);if(!s)return this.hideThumbnailHolder();var i=(1-(e-t)/e)*e;if(this.thumbnailHolder.style.transform="translateX("+i+"px)",this.thumbnailHolder.style.marginLeft="-"+parseInt(s.width)/2+"px",!this.lastStyle||this.lastStyle!==s)for(var o in this.lastStyle=s)s.hasOwnProperty(o)&&(this.thumbnailHolder.style[o]=s[o])},r.prototype.processVtt=function(t){var a=this,u=[];return t.split(/[\r\n][\r\n]/i).forEach(function(t){if(t.match(/([0-9]{2}:)?([0-9]{2}:)?[0-9]{2}(.[0-9]{3})?( ?--> ?)([0-9]{2}:)?([0-9]{2}:)?[0-9]{2}(.[0-9]{3})?[\r\n]{1}.*/gi)){var e=t.split(/[\r\n]/i),r=e[0].split(/ ?--> ?/i),s=r[0],i=r[1],o=e[1],n=a.getVttCss(o);u.push({start:a.getSecondsFromTimestamp(s),end:a.getSecondsFromTimestamp(i),css:n})}}),u},r.prototype.getFullyQualifiedUrl=function(t,e){return 0<=t.indexOf("//")?t:0===e.indexOf("//")?[e.replace(/\/$/gi,""),this.trim(t,"/")].join("/"):0<e.indexOf("//")?[this.trim(e,"/"),this.trim(t,"/")].join("/"):t},r.prototype.getPropsFromDef=function(t){var e=t.split(/#xywh=/i),r=e[0],s=e[1].match(/[0-9]+/gi);return{x:s[0],y:s[1],w:s[2],h:s[3],image:r}},r.prototype.getVttCss=function(t){var e={},r=void 0;if(r=0<=this.options.src.indexOf("//")?this.options.src.split(/([^\/]*)$/gi).shift():this.getBaseUrl()+this.options.src.split(/([^\/]*)$/gi).shift(),!(t=this.getFullyQualifiedUrl(t,r)).match(/#xywh=/i))return e.background='url("'+t+'")',e;var s=this.getPropsFromDef(t);return e.background='url("'+s.image+'") no-repeat -'+s.x+"px -"+s.y+"px",e.width=s.w+"px",e.height=s.h+"px",e},r.prototype.doconstructTimestamp=function(t){var e=t.split("."),r=e[0].split(":");return{milliseconds:parseInt(e[1])||0,seconds:parseInt(r.pop())||0,minutes:parseInt(r.pop())||0,hours:parseInt(r.pop())||0}},r.prototype.getSecondsFromTimestamp=function(t){var e=this.doconstructTimestamp(t);return parseInt(3600*e.hours+60*e.minutes+e.seconds+e.milliseconds/1e3)},r.prototype.trim=function(t,e){var r=[" ","\n","\r","\t","\f","\v"," "," "," "," "," "," "," "," "," "," "," "," ","​","\u2028","\u2029"," "].join(""),s=0,i=0;for(t+="",e&&(r=(e+"").replace(/([[\]().?/*{}+$^:])/g,"$1")),s=t.length,i=0;i<s;i++)if(-1===r.indexOf(t.charAt(i))){t=t.substring(i);break}for(i=(s=t.length)-1;0<=i;i--)if(-1===r.indexOf(t.charAt(i))){t=t.substring(0,i+1);break}return-1===r.indexOf(t.charAt(0))?t:""},r}();return t("vttThumbnails",e),e.VERSION="0.0.13",e}); \ No newline at end of file
diff --git a/src/invidious.cr b/src/invidious.cr
index 1d2e63b2..1399c124 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -3053,6 +3053,86 @@ get "/api/v1/stats" do |env|
statistics.to_json
end
+# YouTube provides "storyboards", which are sprites containing of x * y
+# preview thumbnails for individual scenes in a video.
+# See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails
+get "/api/v1/storyboards/:id" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ env.response.content_type = "application/json"
+
+ id = env.params.url["id"]
+ region = env.params.query["region"]?
+
+ client = make_client(YT_URL)
+ begin
+ video = get_video(id, PG_DB, proxies, region: region)
+ rescue ex : VideoRedirect
+ next env.redirect "/api/v1/storyboards/#{ex.message}"
+ rescue ex
+ env.response.status_code = 500
+ next
+ end
+
+ storyboards = video.storyboards
+
+ width = env.params.query["width"]?
+ height = env.params.query["height"]?
+
+ if !width && !height
+ response = JSON.build do |json|
+ json.object do
+ json.field "storyboards" do
+ generate_storyboards(json, id, storyboards, config, Kemal.config)
+ end
+ end
+ end
+
+ next response
+ end
+
+ env.response.content_type = "text/vtt"
+
+ storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" }
+
+ if storyboard.empty?
+ env.response.status_code = 404
+ next
+ else
+ storyboard = storyboard[0]
+ end
+
+ webvtt = <<-END_VTT
+ WEBVTT
+
+
+ END_VTT
+
+ start_time = 0.milliseconds
+ end_time = storyboard[:interval].milliseconds
+
+ storyboard[:storyboard_count].times do |i|
+ host_url = make_host_url(config, Kemal.config)
+ url = storyboard[:url].gsub("$M", i).gsub("https://i9.ytimg.com", host_url)
+
+ storyboard[:storyboard_height].times do |j|
+ storyboard[:storyboard_width].times do |k|
+ webvtt += <<-END_CUE
+ #{start_time}.000 --> #{end_time}.000
+ #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width]},#{storyboard[:height]}
+
+
+ END_CUE
+
+ start_time += storyboard[:interval].milliseconds
+ end_time += storyboard[:interval].milliseconds
+ end
+ end
+ end
+
+ webvtt
+end
+
get "/api/v1/captions/:id" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
@@ -3145,7 +3225,7 @@ get "/api/v1/captions/:id" do |env|
text = "<v #{md["name"]}>#{md["text"]}</v>"
end
- webvtt = webvtt + <<-END_CUE
+ webvtt += <<-END_CUE
#{start_time} --> #{end_time}
#{text}
@@ -5054,6 +5134,13 @@ get "/ggpht/*" do |env|
end
end
+options "/sb/:id/:storyboard/:index" do |env|
+ env.response.headers.delete("Content-Type")
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+ env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
+ env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
+end
+
get "/sb/:id/:storyboard/:index" do |env|
id = env.params.url["id"]
storyboard = env.params.url["storyboard"]
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index 755ee4d7..9a199fad 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -281,7 +281,7 @@ struct Video
generate_thumbnails(json, self.id, config, kemal_config)
end
json.field "storyboards" do
- generate_storyboards(json, self.storyboards, config, kemal_config)
+ generate_storyboards(json, self.id, self.storyboards, config, kemal_config)
end
description_html, description = html_to_content(self.description)
@@ -1348,11 +1348,12 @@ def generate_thumbnails(json, id, config, kemal_config)
end
end
-def generate_storyboards(json, storyboards, config, kemal_config)
+def generate_storyboards(json, id, storyboards, config, kemal_config)
json.array do
storyboards.each do |storyboard|
json.object do
- json.field "url", storyboard[:url]
+ json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
+ json.field "templateUrl", storyboard[:url]
json.field "width", storyboard[:width]
json.field "height", storyboard[:height]
json.field "count", storyboard[:count]
diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr
index 6ad70033..3afa2af8 100644
--- a/src/invidious/views/components/player.ecr
+++ b/src/invidious/views/components/player.ecr
@@ -217,6 +217,10 @@ if (bpb) {
player.httpSourceSelector();
<% end %>
+player.vttThumbnails({
+ src: 'api/v1/storyboards/<%= video.id %>?height=90'
+});
+
<% if !params.listen && params.annotations %>
var video_container = document.getElementById('player');
let xhr = new XMLHttpRequest();
diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr
index f446248e..2fbd41b0 100644
--- a/src/invidious/views/components/player_sources.ecr
+++ b/src/invidious/views/components/player_sources.ecr
@@ -2,12 +2,14 @@
<link rel="stylesheet" href="/css/videojs-http-source-selector.css">
<link rel="stylesheet" href="/css/videojs.markers.min.css">
<link rel="stylesheet" href="/css/videojs-share.css">
+<link rel="stylesheet" href="/css/videojs-vtt-thumbnails.css">
<script src="/js/video.min.js"></script>
<script src="/js/videojs-contrib-quality-levels.min.js"></script>
<script src="/js/videojs-http-source-selector.min.js"></script>
<script src="/js/videojs.hotkeys.min.js"></script>
<script src="/js/videojs-markers.min.js"></script>
<script src="/js/videojs-share.min.js"></script>
+<script src="/js/videojs-vtt-thumbnails.min.js"></script>
<% if params.annotations %>
<link rel="stylesheet" href="/css/videojs-youtube-annotations.min.css">
diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr
index bab7762a..8561dee9 100644
--- a/src/invidious/views/licenses.ecr
+++ b/src/invidious/views/licenses.ecr
@@ -95,6 +95,20 @@
<tr>
<td>
+ <a href="/js/videojs-vtt-thumbnails.min.js">videojs-vtt-thumbnails.min.js</a>
+ </td>
+
+ <td>
+ <a href="http://www.jclark.com/xml/copying.txt">Expat</a>
+ </td>
+
+ <td>
+ <a href="https://github.com/chrisboustead/videojs-vtt-thumbnails"><%= translate(locale, "source") %></a>
+ </td>
+ </tr>
+
+ <tr>
+ <td>
<a href="/js/videojs-youtube-annotations.min.js">videojs-youtube-annotations.min.js</a>
</td>