CapTube

"S"キーでYouTubeのスクリーンショット保存

  1. // ==UserScript==
  2. // @name CapTube
  3. // @namespace https://github.com/segabito/
  4. // @description "S"キーでYouTubeのスクリーンショット保存
  5. // @include https://www.youtube.com/*
  6. // @include https://www.youtube.com/embed/*
  7. // @include https://youtube.com/*
  8. // @version 0.0.10
  9. // @grant none
  10. // @license public domain
  11. // ==/UserScript==
  12.  
  13. (function() {
  14.  
  15. let previewContainer = null, meterContainer = null;
  16. const addStyle = function(styles, id) {
  17. var elm = document.createElement('style');
  18. elm.type = 'text/css';
  19. if (id) { elm.id = id; }
  20.  
  21. var text = styles.toString();
  22. text = document.createTextNode(text);
  23. elm.appendChild(text);
  24. var head = document.getElementsByTagName('head');
  25. head = head[0];
  26. head.appendChild(elm);
  27. return elm;
  28. };
  29.  
  30. const createWebWorker = function(func) {
  31. const src = func.toString().replace(/^function.*?\{/, '').replace(/}$/, '');
  32. const blob = new Blob([src], {type: 'text\/javascript'});
  33. const url = URL.createObjectURL(blob);
  34.  
  35. return new Worker(url);
  36. };
  37.  
  38. const callOnIdle = function(func) {
  39. if (window.requestIdleCallback) {
  40. window.requestIdleCallback(func);
  41. } else {
  42. setTimeout(func, 0);
  43. }
  44. };
  45.  
  46. const DataUrlConv = (function() {
  47. const sessions = {};
  48.  
  49. const func = function(self) {
  50. self.onmessage = function(e) {
  51. const dataURL = e.data.dataURL;
  52. const sessionId = e.data.sessionId;
  53.  
  54. const bin = atob(dataURL.split(',')[1]);
  55. const buf = new Uint8Array(bin.length);
  56.  
  57. for (let i = 0, len = buf.length; i < len; i++) {
  58. buf[i] = bin.charCodeAt(i);
  59. }
  60.  
  61. const blob = new Blob([buf.buffer], {type: 'image/png'});
  62. const objectURL = URL.createObjectURL(blob);
  63. self.postMessage({objectURL, sessionId});
  64. };
  65. };
  66.  
  67. const worker = createWebWorker(func);
  68. worker.addEventListener('message', (e) => {
  69. const sessionId = e.data.sessionId;
  70. if (!sessions[sessionId]) { return; }
  71.  
  72. (sessions[sessionId])(e.data.objectURL);
  73. delete sessions[sessionId];
  74. });
  75.  
  76. return {
  77. toObjectURL: function(dataURL) {
  78. return new Promise(resolve => {
  79. const sessionId = 'id:' + Math.random();
  80. sessions[sessionId] = resolve;
  81. worker.postMessage({dataURL, sessionId});
  82. });
  83. }
  84. };
  85. })();
  86.  
  87.  
  88. const __css__ = (`
  89. #CapTubePreviewContainer {
  90. position: fixed;
  91. padding: 16px 0 0 16px;
  92. width: 90%;
  93. bottom: 100px;
  94. left: 5%;
  95. z-index: 10000;
  96. pointer-events: none;
  97. transform: translateZ(0);
  98. /*background: rgba(192, 192, 192, 0.4);*/
  99. border: 1px solid #ccc;
  100. -webkit-user-select: none;
  101. user-select: none;
  102. }
  103.  
  104. #CapTubePreviewContainer:empty {
  105. display: none;
  106. }
  107. #CapTubePreviewContainer canvas {
  108. display: inline-block;
  109. width: 256px;
  110. margin-right: 16px;
  111. margin-bottom: 16px;
  112. outline: solid 1px #ccc;
  113. outline-offset: 4px;
  114. transform: translateZ(0);
  115. transition:
  116. 1s opacity linear,
  117. 1s margin-right linear;
  118. }
  119.  
  120. #CapTubePreviewContainer canvas.is-removing {
  121. opacity: 0;
  122. margin-right: -272px;
  123. /*width: 0;*/
  124. }
  125.  
  126. #CapTubeMeterContainer {
  127. pointer-events: none;
  128. position: fixed;
  129. width: 26px;
  130. bottom: 100px;
  131. left: 16px;
  132. z-index: 10000;
  133. border: 1px solid #ccc;
  134. transform: translateZ(0);
  135. -webkit-user-select: none;
  136. user-select: none;
  137. }
  138.  
  139. #CapTubeMeterContainer::after {
  140. content: 'queue';
  141. position: absolute;
  142. bottom: -2px;
  143. left: 50%;
  144. transform: translate(-50%, 100%);
  145. color: #666;
  146. }
  147.  
  148. #CapTubeMeterContainer:empty {
  149. display: none;
  150. }
  151.  
  152. #CapTubeMeterContainer .memory {
  153. display: block;
  154. width: 24px;
  155. height: 8px;
  156. margin: 1px 0 0;
  157. background: darkgreen;
  158. opacity: 0.5;
  159. border: 1px solid #ccc;
  160. }
  161.  
  162. `).trim();
  163.  
  164. addStyle(__css__);
  165.  
  166. const getVideoId = function() {
  167. var id = '';
  168. location.search.substring(1).split('&').forEach(function(item){
  169. if (item.split('=')[0] === 'v') { id = item.split('=')[1]; }
  170. });
  171. return id;
  172. };
  173.  
  174. const toSafeName = function(text) {
  175. text = text.trim()
  176. .replace(/</g, '<')
  177. .replace(/>/g, '>')
  178. .replace(/\?/g, '?')
  179. .replace(/:/g, ':')
  180. .replace(/\|/g, '|')
  181. .replace(/\//g, '/')
  182. .replace(/\\/g, '¥')
  183. .replace(/"/g, '”')
  184. .replace(/\./g, '.')
  185. ;
  186. return text;
  187. };
  188.  
  189. const getVideoTitle = function(params = {title, videoId, author}) {
  190. var prefix = localStorage['CapTube-prefix'] || '';
  191. var videoId = params.videoId || getVideoId();
  192. var title = document.querySelector('.title yt-formatted-string') || document.querySelector('.watch-title') || {textContent: document.title};
  193. var authorName = toSafeName(
  194. params.author || document.querySelector('#owner-container yt-formatted-string').textContent || '');
  195. var titleText = toSafeName(params.title || title.textContent);
  196. titleText = prefix + titleText + ' - by ' + authorName + ' (v=' + videoId + ')';
  197.  
  198. return titleText;
  199. };
  200.  
  201. const createCanvasFromVideo = function(video) {
  202. console.time('createCanvasFromVideo');
  203. const width = video.videoWidth;
  204. const height = video.videoHeight;
  205. const canvas = document.createElement('canvas');
  206. canvas.width = width;
  207. canvas.height = height;
  208. const context = canvas.getContext('2d');
  209. context.drawImage(video, 0, 0);
  210.  
  211.  
  212. const thumbnail = document.createElement('canvas');
  213. thumbnail.width = 256;
  214. thumbnail.height = canvas.height * (256 / canvas.width);
  215. thumbnail.getContext('2d').drawImage(canvas, 0, 0, thumbnail.width, thumbnail.height);
  216. console.timeEnd('createCanvasFromVideo');
  217.  
  218. return {canvas, thumbnail};
  219. };
  220.  
  221. const getFileName = function(video, params = {title, videoId, author}) {
  222. const title = getVideoTitle(params);
  223. const currentTime = video.currentTime;
  224. const min = Math.floor(currentTime / 60);
  225. const sec = (currentTime % 60 + 100).toString().substr(1, 6);
  226. const time = `${min}_${sec}`;
  227.  
  228. return `${title}@${time}.png`;
  229. };
  230. /*
  231. const createBlobLinkElement = function(canvas, fileName) {
  232. console.time('createBlobLinkElement');
  233.  
  234. console.time('canvas.toDataURL');
  235. const dataURL = canvas.toDataURL('image/png');
  236. console.timeEnd('canvas.toDataURL');
  237.  
  238. console.time('createObjectURL');
  239. const bin = atob(dataURL.split(',')[1]);
  240. const buf = new Uint8Array(bin.length);
  241. for (let i = 0, len = buf.length; i < len; i++) { buf[i] = bin.charCodeAt(i); }
  242. const blob = new Blob([buf.buffer], {type: 'image/png'});
  243. const url = window.URL.createObjectURL(blob);
  244. console.timeEnd('createObjectURL');
  245.  
  246. const link = document.createElement('a');
  247. link.setAttribute('download', fileName);
  248. link.setAttribute('target', '_blank');
  249. link.setAttribute('href', url);
  250.  
  251. console.timeEnd('createBlobLinkElement');
  252. return link;
  253. };
  254. */
  255.  
  256. const createBlobLinkElementAsync = function(canvas, fileName) {
  257. //console.time('createBlobLinkElement');
  258.  
  259. console.time('canvas to DataURL');
  260. const dataURL = canvas.toDataURL('image/png');
  261. console.timeEnd('canvas to DataURL');
  262.  
  263. console.time('dataURL to objectURL');
  264.  
  265. return DataUrlConv.toObjectURL(dataURL).then(objectURL => {
  266. console.timeEnd('dataURL to objectURL');
  267.  
  268. const link = document.createElement('a');
  269. link.setAttribute('download', fileName);
  270. //link.setAttribute('target', '_blank');
  271. link.setAttribute('href', objectURL);
  272.  
  273. //console.timeEnd('createBlobLinkElement');
  274. return Promise.resolve(link);
  275. });
  276. };
  277.  
  278. const saveScreenShot = function(params = {title, videoId, author}) {
  279. const video = document.querySelector('.html5-main-video');
  280. if (!video) { return; }
  281.  
  282. const meter = document.createElement('div');
  283. if (meterContainer) {
  284. meter.className = 'memory';
  285. meterContainer.appendChild(meter);
  286. }
  287.  
  288. const {canvas, thumbnail} = createCanvasFromVideo(video);
  289. const fileName = getFileName(video, params);
  290.  
  291. const create = () => {
  292. createBlobLinkElementAsync(canvas, fileName).then(link => {
  293. document.body.appendChild(link);
  294. link.click();
  295. setTimeout(() => {
  296. link.remove();
  297. meter.remove();
  298. URL.revokeObjectURL(link.getAttribute('href'));
  299. }, 1000);
  300. });
  301. };
  302.  
  303. callOnIdle(create);
  304.  
  305. if (!previewContainer) { return; }
  306. previewContainer.appendChild(thumbnail);
  307. setTimeout(() => {
  308. thumbnail.classList.add('is-removing');
  309. setTimeout(() => { thumbnail.remove(); }, 2000);
  310. }, 1500);
  311. };
  312.  
  313. const setPlaybackRate = function(v) {
  314. const video = document.querySelector('.html5-main-video');
  315. if (!video) { return; }
  316. video.playbackRate = v;
  317. };
  318.  
  319. const togglePlay = function() {
  320. const video = document.querySelector('.html5-main-video');
  321. if (!video) { return; }
  322.  
  323. if (video.paused) {
  324. video.play();
  325. } else {
  326. video.pause();
  327. }
  328. };
  329.  
  330. const seekBy = function(v) {
  331. const video = document.querySelector('.html5-main-video');
  332. if (!video) { return; }
  333.  
  334. const ct = Math.max(video.currentTime + v, 0);
  335. video.currentTime = ct;
  336. };
  337.  
  338. let isVerySlow = false;
  339. const onKeyDown = (e) => {
  340. const key = e.key.toLowerCase();
  341. switch (key) {
  342. case 'd':
  343. setPlaybackRate(0.1);
  344. isVerySlow = true;
  345. break;
  346. case 's':
  347. saveScreenShot({});
  348. break;
  349. }
  350. };
  351.  
  352. const onKeyUp = (e) => {
  353. //console.log('onKeyUp', e);
  354. const key = e.key.toLowerCase();
  355. switch (key) {
  356. case 'd':
  357. setPlaybackRate(1);
  358. isVerySlow = false;
  359. break;
  360. }
  361. };
  362.  
  363. const onKeyPress = (e) => {
  364. const key = e.key.toLowerCase();
  365. switch (key) {
  366. case 'w':
  367. togglePlay();
  368. break;
  369. case 'a':
  370. seekBy(isVerySlow ? -0.5 : -5);
  371. break;
  372. }
  373. };
  374.  
  375.  
  376. const initDom = function() {
  377. const div = document.createElement('div');
  378. div.id = 'CapTubePreviewContainer';
  379. document.body.appendChild(div);
  380. previewContainer = div;
  381.  
  382. meterContainer = document.createElement('div');
  383. meterContainer.id = 'CapTubeMeterContainer';
  384. document.body.appendChild(meterContainer);
  385. };
  386.  
  387. const HOST_REG = /^[a-z0-9]*\.nicovideo\.jp$/;
  388.  
  389. const parseUrl = (url) => {
  390. const a = document.createElement('a');
  391. a.href = url;
  392. return a;
  393. };
  394.  
  395.  
  396. const initialize = function() {
  397. initDom();
  398. window.addEventListener('keydown', onKeyDown);
  399. window.addEventListener('keyup', onKeyUp);
  400. window.addEventListener('keypress', onKeyPress);
  401. };
  402.  
  403. const initializeEmbed = function() {
  404. let parentHost = parseUrl(document.referrer).hostname;
  405. if (!HOST_REG.test(parentHost)) {
  406. window.console.log('disable bridge');
  407. return;
  408. }
  409.  
  410. console.log('%cinit embed CapTube', 'background: lightgreen;');
  411. window.addEventListener('message', event => {
  412. if (!HOST_REG.test(parseUrl(event.origin).hostname)) { return; }
  413. let data = JSON.parse(event.data), command = data.command;
  414.  
  415. switch (command) {
  416. case 'capture':
  417. saveScreenShot({
  418. title: data.title,
  419. videoId: data.videoId,
  420. author: data.author
  421. });
  422. break;
  423. }
  424. });
  425.  
  426. };
  427.  
  428. if (window.top !== window && location.pathname.indexOf('/embed/') === 0) {
  429. initializeEmbed();
  430. } else {
  431. initialize();
  432. }
  433. })();