AbemaTV Video Assistant

AbemaTV のビデオ視聴を快適にします。

目前为 2019-07-13 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name AbemaTV Video Assistant
  3. // @namespace knoa.jp
  4. // @description AbemaTV のビデオ視聴を快適にします。
  5. // @include https://abema.tv/*
  6. // @version 1.0.3
  7. // @grant none
  8. // ==/UserScript==
  9.  
  10. // console.log('AbemaTV? => hireMe()');
  11. (function(){
  12. const SCRIPTNAME = 'VideoAssistant';
  13. const DEBUG = false;/*
  14. [update] 1.0.3
  15. 再生速度が「次のエピソード」への移動でリセットされてしまうバグを解消。(またしてもアベマ公式の謎仕様に起因(。◟‸◞。✿))
  16.  
  17. [bug]
  18.  
  19. [to do]
  20. video以外のページならgoneでcss外すか。
  21. 時刻ズレが気持ち悪いので全体時間も上書きしよう。
  22.  
  23. [to research]
  24. 無音時に限り音量が記憶されない公式のバグ
  25. CMで一瞬ヘッダがでるのは公式の仕様
  26.  
  27. [possible]
  28. 上部ナビゲーション番組表の隣にマイリスト昇格
  29. ビデオ視聴ページ
  30. 戻る進む時に画面中央にインジケータ
  31. コメント数ヒートマップ ?
  32. マイリストページ
  33. 一覧性向上や分類など
  34. 「プレミアムなら」を明示
  35. 期限切れをすべて削除するボタン
  36. ビデオトップページ
  37. removed_genre: {TYPE: 'object', DEFAULT: {}},*(削除したジャンル)*
  38. removed_heading: {TYPE: 'object', DEFAULT: {}},*(削除した見出し)*
  39.  
  40. [requests]
  41.  
  42. [not to do]
  43. :has疑似セレクタ実装はまだまだ先になりそう
  44. */
  45. if(window === top && console.time) console.time(SCRIPTNAME);
  46. const CONFIGS = {
  47. /* ビデオ再生 */
  48. auto_play: {TYPE: 'bool', DEFAULT: 1 },/*自動で再生を開始する*/
  49. keep_screen: {TYPE: 'bool', DEFAULT: 0 },/*ブラウザ全画面かどうかを記憶する*/
  50. keep_speed: {TYPE: 'bool', DEFAULT: 0 },/*再生速度を記憶する*/
  51. /* 次のエピソードへの移動 */
  52. show_next: {TYPE: 'bool', DEFAULT: 1 },/*次のエピソードへの移動ボタンを出す*/
  53. next_at_end: {TYPE: 'bool', DEFAULT: 0 },/* ビデオの最後まで再生してから出す*/
  54. next_countdown: {TYPE: 'bool', DEFAULT: 1 },/* カウントダウンして自動移動する*/
  55. };
  56. const URLS = {
  57. CHANNELS: 'https://abema.tv/channels/',/*見逃し番組視聴ページ(未来や期限切れも含む)*/
  58. VIDEO: 'https://abema.tv/video/episode/',/*ビデオ視聴ページ(期限切れも含む)*/
  59. };
  60. const EASING = 'cubic-bezier(0,.75,.5,1)';/*主にナビゲーションのアニメーション用*/
  61. const RETRY = 10;/*必要な要素が見つからずあきらめるまでの試行回数*/
  62. let site = {
  63. videoTargets: {
  64. video: () => $('.com-a-Video__container video[src]'),/*CMとは区別する*/
  65. },
  66. adVideoTargets: {
  67. adContainer: () => $('#videoAdContainer'),
  68. adVideos: () => $$('#videoAdContainer video'),/*CM*/
  69. adVideoController: () => $('.com-video_ad-VideoAdControlBar__controls'),
  70. },
  71. elementTargets: {
  72. player: () => $('.c-tv-SlotPlayerContainer') || $('.c-vod-PlayerContainer-wrapper'),/*タイムシフトまたはビデオ*/
  73. controlBackground: () => $('.com-vod-VideoControlBar__bg'),
  74. playButton: () => $('.com-vod-VideoControlBar__play-handle'),
  75. currentTime: () => $('.com-vod-VODTime > span > time'),
  76. playbackRateButton: () => $('.com-vod-VideoControlBar__playback-rate'),
  77. VolumeController: () => $('.com-vod-VideoControlBar__volume'),
  78. },
  79. nextButtonTargets: {/*次のエピソードへ*/
  80. nextButton: () => $('.com-vod-VODNextProgramInfo'),
  81. nextButtonAnchor: () => $('.com-vod-VODNextProgramInfo a[href]'),
  82. nextButtonCount: () => $('.com-video-MediaInfoCard__count'),
  83. nextButtonCountPie: () => $('.com-video-MediaInfoCard__thumbnail > span'),
  84. nextButtonCancel: () => $('.com-vod-VODNextProgramInfo__close-button'),
  85. },
  86. screenButtonTargets: {/*画面サイズボタン*/
  87. fullScreenInBrowserButton: () => $('.com-vod-VideoControlBar__screen-controller'),
  88. fullScreenButton: () => $('.com-vod-VideoControlBar__screen-controller + .com-vod-VideoControlBar__screen-controller'),
  89. },
  90. screenButtonOnAdTargets: {/*CM中の画面サイズボタン*/
  91. fullScreenInBrowserButtonOnAd: () => $('.com-video_ad-VideoAdControlBar__button'),
  92. fullScreenButtonOnAd: () => $('.com-video_ad-VideoAdControlBar__button + .com-video_ad-VideoAdControlBar__button'),
  93. },
  94. get: {
  95. playbackImage: () => $('.com-vod-VODScreen-playback-image'),/*再生/停止のオーバーレイインジケータ*/
  96. playbackIcon: (button) => button.querySelector('use[*|href^="/images/icons/playback.svg"]'),/*再生/停止ボタンの再生アイコン*/
  97. currentPlaybackRate: () => $('.com-a-RadioButton--checked input[name="vod-setting-playbackRate"]'),/*現在選択中の再生速度*/
  98. targetPlaybackRate: (value) => $(`input[name="vod-setting-playbackRate"][value="${value}"]`),/*目的の再生速度*/
  99. miniScreenInBrowserIcon: (button) => button.querySelector('use[*|href^="/images/icons/mini_screen_in_browser.svg"]'),
  100. fullScreenInBrowserIcon: (button) => button.querySelector('use[*|href^="/images/icons/full_screen_in_browser.svg"]'),
  101. miniScreenIcon: (button) => button.querySelector('use[*|href^="/images/icons/mini_screen.svg"]'),/*元の小画面または中画面に戻る*/
  102. fullScreenIcon: (button) => button.querySelector('use[*|href^="/images/icons/full_screen.svg"]'),
  103. nextProgramThumbnail: (button) => button.querySelector('a img[alt]'),
  104. },
  105. };
  106. let elements = {}, storages = {}, configs = {}, timers = {};
  107. let core = {
  108. initialize: function(){
  109. html = document.documentElement;
  110. core.config.read();
  111. core.addStyle();
  112. core.panel.createPanels();
  113. core.listenUserActions();
  114. core.checkUrl();
  115. },
  116. checkUrl: function(){
  117. let previousUrl = '';
  118. const videoPages = [URLS.CHANNELS, URLS.VIDEO];
  119. const isVideoPage = () => videoPages.some(url => location.href.startsWith(url));
  120. const wasVideoPage = () => videoPages.some(url => previousUrl.startsWith(url));
  121. setInterval(function(){
  122. switch(true){
  123. case(location.href === previousUrl): return;/*URLが変わってない*/
  124. case(isVideoPage()):/*ビデオ視聴ページ*/
  125. core.videoReady();/*ビデオ視聴ページに来た*/
  126. break;
  127. default:/*ビデオ視聴ページではない*/
  128. break;
  129. }
  130. previousUrl = location.href;
  131. }, 1000);
  132. },
  133. videoReady: function(){
  134. let previousSrc = (elements.video) ? elements.video.src : null;
  135. core.getTargets(site.videoTargets, RETRY).then(() => {
  136. if(elements.video.src === previousSrc) setTimeout(core.videoReady, 1000);/*まだDOMが差し替わってない*/
  137. log("I'm ready for video.");
  138. html.classList.add(SCRIPTNAME);
  139. core.setAutoPlay();
  140. core.adVideosReady();
  141. core.elementsReady();
  142. });
  143. },
  144. adVideosReady: function(){
  145. core.getTargets(site.adVideoTargets, RETRY).then(() => {
  146. log("I'm ready for ad videos.");
  147. core.keepScreen();
  148. core.makeAdsPausable();
  149. core.waitForAdEnded();
  150. });
  151. },
  152. elementsReady: function(){
  153. core.getTargets(site.elementTargets, RETRY).then(() => {
  154. log("I'm ready for elements.");
  155. core.config.createButton();
  156. core.replaceVideoTime();
  157. core.keepScreen();
  158. core.keepSpeed();
  159. core.alterNextButton();
  160. core.modifyPlayButton();
  161. });
  162. },
  163. getTargets: function(targets, retry = 0){
  164. const get = function(resolve, reject, retry){
  165. for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
  166. let selected = targets[key]();
  167. if(selected){
  168. if(selected.length) selected.forEach((s) => s.dataset.selector = key);
  169. else selected.dataset.selector = key;
  170. elements[key] = selected;
  171. }else{
  172. if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
  173. log(`Not found: ${key}, retrying... (left ${retry})`);
  174. return setTimeout(get, 1000, resolve, reject, retry);
  175. }
  176. }
  177. resolve();
  178. };
  179. return new Promise(function(resolve, reject){
  180. get(resolve, reject, retry);
  181. });
  182. },
  183. listenUserActions: function(){
  184. document.addEventListener('fullscreenchange', function(e){
  185. if(document.fullscreenElement){/*フルスクリーンなら*/
  186. document.fullscreenElement.appendChild(elements.panels);
  187. }else{
  188. document.body.appendChild(elements.panels);
  189. }
  190. });
  191. },
  192. makeAdsPausable: function(){
  193. let adContainer = elements.adContainer, adVideoController = elements.adVideoController, cuurentAd = undefined;
  194. const toggle = function(e){
  195. if(!cuurentAd){
  196. cuurentAd = Array.from(elements.adVideos).find((v) => !v.paused);/*elements.にしないとlistener登録した時点の古いDOMを引きずる*/
  197. if(cuurentAd) cuurentAd.pause();
  198. }else{
  199. cuurentAd.play();
  200. cuurentAd = undefined;
  201. }
  202. };
  203. if(!adContainer.isListeningClick){/*要素ごとに1度だけ*/
  204. adContainer.isListeningClick = true;
  205. adContainer.addEventListener('click', function(e){
  206. if(adVideoController.contains(e.target)) return;
  207. toggle(e);
  208. }, {capture: true});
  209. }
  210. if(!core.makeAdsPausable.isListeningKeydown){/*スクリプトごとに1度だけ*/
  211. core.makeAdsPausable.isListeningKeydown = true;
  212. if(html.classList.contains('ShortcutKeyController')){
  213. window.addEventListener('keydown', function(e){
  214. if(['input', 'textarea'].includes(document.activeElement.localName)) return;
  215. if(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey) return;
  216. if(['k', ' ', 'Enter'].includes(e.key)) toggle(e);
  217. }, {capture: true});
  218. }
  219. }
  220. },
  221. waitForAdEnded: function(){
  222. let adVideos = elements.adVideos;
  223. adVideos.forEach((v) => v.addEventListener('ended', core.elementsReady));
  224. },
  225. replaceVideoTime: function(){
  226. let video = elements.video, currentTime = elements.currentTime, replacedCurrentTime = currentTime.cloneNode(true);
  227. const secondsToTime = function(seconds){
  228. let floor = Math.floor, zero = (s) => s.toString().padStart(2, '0');
  229. let h = floor(seconds/3600), m = floor(seconds/60)%60, s = floor(seconds%60);
  230. if(h) return h + ':' + zero(m) + ':' + zero(s);
  231. else return m + ':' + zero(s);
  232. };
  233. const tiktok = function(e){/*なおアベマ公式はdelay調整無しで1秒ごとに四捨五入*/
  234. let delay = ((1 - video.currentTime%1) / video.playbackRate)*1000;/*次の秒になるまでの時間(足りなくてももう一度呼ばれて問題ない)*/
  235. if(0.5 < delay) replacedCurrentTime.textContent = secondsToTime(video.currentTime);
  236. clearInterval(timers.tiktok), timers.tiktok = setTimeout(tiktok, delay);
  237. };
  238. /* 独自要素に置き換える */
  239. replacedCurrentTime.dataset.selector = 'replacedCurrentTime';
  240. currentTime.parentNode.insertBefore(replacedCurrentTime, currentTime);
  241. /* 再生中に独自要素を更新し続ける */
  242. if(!video.paused) tiktok();
  243. video.addEventListener('play', tiktok);
  244. video.addEventListener('pause', function(e){
  245. clearInterval(timers.tiktok);
  246. });
  247. video.addEventListener('seeked', function(e){
  248. replacedCurrentTime.textContent = secondsToTime(video.currentTime);
  249. });
  250. },
  251. setAutoPlay: function(){
  252. let video = elements.video, nextButton = elements.nextButton || createElement(), playbackImage = site.get.playbackImage() || createElement();
  253. let conditions = [[/*停止状態にしたい条件*/
  254. (configs.auto_play === 0),/*自動で再生を開始しない*/
  255. (location.href.endsWith('?next=true') === false),/*1つめのエピソードである*/
  256. ], [
  257. (configs.auto_play === 0),/*自動で再生を開始しない*/
  258. (nextButton.videoWasPaused === true),/*ビデオが停止中であった*/
  259. (nextButton.clicked === true),/*リンクをみずからクリック(カウントダウンではない)*/
  260. ]];
  261. const pause = function(e){
  262. video.pause();
  263. setTimeout(function(){playbackImage.style.visibility = ''}, 1500);/*1000で足りないこともあったので*/
  264. video.removeEventListener('canplay', pause);
  265. };
  266. if(conditions.some((set) => set.every((c) => (c === true)))){
  267. playbackImage.style.visibility = 'hidden';
  268. video.addEventListener('canplay', pause);/*一瞬音声が流れてしまうこともある*/
  269. }
  270. /* setAutoPlayが呼ばれる(新しい番組)ごとにリセット */
  271. nextButton.videoWasPaused = false;
  272. nextButton.clicked = false;
  273. },
  274. modifyPlayButton: function(){
  275. let video = elements.video, playButton = elements.playButton;
  276. let conditions = [/*アイコンを修正すべき条件*/
  277. (configs.auto_play === 0),/*自動で再生を開始しない*/
  278. (video.paused === true),/*停止している*/
  279. (site.get.playbackIcon(playButton) === null),/*にもかかわらずアイコンが停止状態を示していない!!*/
  280. ];
  281. if(conditions.every((c) => c === true)) playButton.click();/*アイコンだけ停止状態になる*/
  282. },
  283. keepScreen: function(){
  284. /* fullScreenInBrowserButton(小画面と中画面のトグル) + fullScreenButton(全画面:DOM再取得が必要) */
  285. Promise.race([
  286. core.getTargets(site.screenButtonTargets, RETRY),
  287. core.getTargets(site.screenButtonOnAdTargets, RETRY),
  288. ]).then(() => {
  289. let video = elements.video;
  290. let fullScreenInBrowserButton = [elements.fullScreenInBrowserButton, elements.fullScreenInBrowserButtonOnAd].find((e) => e && e.isConnected);
  291. let fullScreenButton = [elements.fullScreenButton, elements.fullScreenButtonOnAd].find((e) => e && e.isConnected);
  292. const DELAY = 1000;/*画面サイズの変更にかかる時間を確保*/
  293. const getCurrentScreen = function(){
  294. switch(true){
  295. case(site.get.fullScreenInBrowserIcon(fullScreenInBrowserButton) !== null):
  296. return 'miniScreenInBrowser';/*小画面*/
  297. case(site.get.miniScreenInBrowserIcon(fullScreenInBrowserButton) !== null):
  298. return 'fullScreenInBrowser';/*中画面*/
  299. case(site.get.miniScreenIcon(fullScreenButton) !== null):
  300. return 'fullScreen';/*全画面*/
  301. }
  302. };
  303. const saveScreen = function(e){
  304. Storage.save('screen', getCurrentScreen());
  305. };
  306. const setScreen = function(){
  307. switch(Storage.read('screen')){
  308. case('miniScreenInBrowser'):/*小画面*/
  309. return;
  310. case('fullScreenInBrowser'):/*中画面*/
  311. return fullScreenInBrowserButton.click();
  312. case('fullScreen'):/*全画面*/
  313. return fullScreenButton.click();/*ブラウザ仕様につき機能しない*/
  314. }
  315. };
  316. if(!fullScreenInBrowserButton.isListeningClick){/*ボタンごとに1度だけ*/
  317. fullScreenInBrowserButton.isListeningClick = true;
  318. fullScreenInBrowserButton.addEventListener('click', function(e){
  319. setTimeout(saveScreen, DELAY);
  320. });
  321. }
  322. if(!core.keepScreen.isListeningFullscreenchange){/*スクリプトごとに1度だけ*/
  323. core.keepScreen.isListeningFullscreenchange = true;
  324. document.addEventListener('fullscreenchange', function(e){
  325. setTimeout(saveScreen, DELAY);
  326. if(!document.fullscreenElement) setTimeout(core.keepScreen, DELAY);/*ボタンが差し替えられるので*/
  327. });
  328. }
  329. if(video.setScreen !== location.href){/*ビデオ内容ごとに1度だけ*/
  330. video.setScreen = location.href;
  331. if(configs.keep_screen) setScreen();/*初回の視聴画面サイズを再現*/
  332. }
  333. });
  334. },
  335. keepSpeed: function(){
  336. let video = elements.video, playbackRateButton = elements.playbackRateButton;
  337. const getCurrentSpeed = function(){
  338. return site.get.currentPlaybackRate().value || 1;
  339. };
  340. const saveSpeed = function(e){
  341. Storage.save('speed', getCurrentSpeed());
  342. };
  343. const setSpeed = function(){
  344. let speed = Storage.read('speed') || 1;
  345. let input = site.get.targetPlaybackRate(speed);
  346. if(input) input.click();/*checkだけではアベマのDOMが反応しない*/
  347. };
  348. if(!playbackRateButton.isListeningRatechange){
  349. playbackRateButton.isListeningRatechange = true;
  350. /* video要素へのratechangeイベントだと、次のエピソードに移ったときにアベマによる強制リセットで元に戻ってしまう */
  351. playbackRateButton.addEventListener('click', function(e){
  352. log(e);
  353. setTimeout(saveSpeed, 1000);
  354. });
  355. }
  356. setSpeed();
  357. },
  358. alterNextButton: function(){
  359. if(!location.href.startsWith(URLS.VIDEO)) return;/*次のエピソードが表示されるのはビデオのみ*/
  360. core.getTargets(site.nextButtonTargets, RETRY).then(() => {
  361. let video = elements.video, nextButton = elements.nextButton, nextButtonAnchor = elements.nextButtonAnchor;
  362. let nextButtonCount = elements.nextButtonCount, nextButtonCancel = elements.nextButtonCancel;
  363. /* ビデオ終了時の独自カウントダウン(再生アイコンのアニメーションは割愛) */
  364. const COUNT = 10;
  365. const countdown = function(){
  366. let node = nextButtonCount.firstChild, count = COUNT;
  367. node.data = String(count);
  368. clearInterval(timers.countdown), timers.countdown = setInterval(function(){
  369. node.data = String(--count);
  370. if(count === 0){
  371. clearInterval(timers.countdown);
  372. nextButton.dataset.shown = 'false';
  373. nextButtonAnchor.click();
  374. }
  375. }, 1000);
  376. };
  377. /* 番組終了間際に自動でボタンが出現する瞬間を検知する */
  378. observe(nextButton, function(records){
  379. if(nextButtonCancel.disabled) return;/*閉じた(つもり)のときは何もしない*/
  380. if(video.ended){
  381. nextButton.dataset.shown = 'true';/*閉じなくてもよい*/
  382. if(configs.next_countdown) countdown();/*独自カウントダウン*/
  383. return;
  384. }
  385. switch(true){
  386. case(configs.show_next === 0):/*すぐ閉じて表示もさせない*/
  387. nextButtonCancel.click();
  388. nextButton.dataset.shown = 'false';
  389. break;
  390. case(configs.next_at_end === 1):/*すぐ閉じて表示もさせない*/
  391. nextButtonCancel.click();
  392. nextButton.dataset.shown = 'false';
  393. break;
  394. case(configs.next_countdown === 0):/*すぐ閉じてカウントダウンさせない*/
  395. nextButtonCancel.click();
  396. nextButton.dataset.shown = 'true';
  397. break;
  398. default:/*閉じずにカウントダウン表示を続ける*/
  399. nextButton.dataset.shown = 'true';
  400. break;
  401. }
  402. }, {attributes: true, attributeFilter: ['class']});/*公式のclass変化のみを監視する*/
  403. nextButton.classList.add('observing');/*すでにボタンが出ていた場合のきっかけにする*/
  404. /* 次のエピソードの自動再生判定のためにボタンの実クリックを記録する */
  405. nextButtonAnchor.addEventListener('click', function(e){
  406. nextButton.videoWasPaused = video.paused;
  407. if(e.isTrusted){
  408. nextButton.clicked = true;
  409. }
  410. });
  411. /* ボタンの表示を独自に制御 */
  412. nextButtonCancel.addEventListener('click', function(e){
  413. if(e.isTrusted){
  414. nextButton.dataset.shown = 'false';/*実クリックされたらもちろん消す*/
  415. clearInterval(timers.countdown);/*独自カウントダウンしていたら止める*/
  416. return;
  417. }
  418. setTimeout(function(){nextButtonCancel.disabled = false}, 1000);/*クリックはいつでもできる(正規のクリック後に上書き)*/
  419. });
  420. if(!video.isListeningSeeking){/*ビデオごとに1度だけ*/
  421. video.isListeningSeeking = true;
  422. video.addEventListener('seeking', function(e){
  423. let thumbnail = site.get.nextProgramThumbnail(nextButton);
  424. if(!thumbnail || thumbnail.alt === '') return;/*次のエピソードなし*/
  425. if(nextButton.dataset.shown === 'true') nextButton.dataset.shown = 'false';
  426. else if(video.currentTime + 1/*許容範囲*/ > video.duration) nextButton.dataset.shown = 'true';
  427. });
  428. }
  429. });
  430. },
  431. config: {
  432. read: function(){
  433. /* 保存済みの設定を読む */
  434. configs = Storage.read('configs') || {};
  435. /* 未定義項目をデフォルト値で上書きしていく */
  436. Object.keys(CONFIGS).forEach((key) => {if(configs[key] === undefined) configs[key] = CONFIGS[key].DEFAULT});
  437. },
  438. save: function(new_config){
  439. configs = {};/*CONFIGSに含まれた設定値のみ保存する*/
  440. /* CONFIGSを元に文字列を型評価して値を格納していく */
  441. Object.keys(CONFIGS).forEach((key) => {
  442. /* 値がなければデフォルト値 */
  443. if(new_config[key] === "") return configs[key] = CONFIGS[key].DEFAULT;
  444. switch(CONFIGS[key].TYPE){
  445. case 'bool':
  446. configs[key] = (new_config[key]) ? 1 : 0;
  447. break;
  448. case 'int':
  449. configs[key] = parseInt(new_config[key]);
  450. break;
  451. case 'float':
  452. configs[key] = parseFloat(new_config[key]);
  453. break;
  454. default:
  455. configs[key] = new_config[key];
  456. break;
  457. }
  458. });
  459. Storage.save('configs', configs);
  460. },
  461. createButton: function(){
  462. if(elements.configButton && elements.configButton.isConnected) return;
  463. /* 再生速度ボタンを元に設定ボタンを追加する */
  464. let configButton = elements.configButton = createElement(core.html.configButton());
  465. configButton.className = elements.playbackRateButton.className;
  466. configButton.addEventListener('click', core.config.toggle);
  467. elements.playbackRateButton.parentNode.insertBefore(configButton, elements.playbackRateButton);/*元のDOM位置関係にできるだけ影響を与えない*/
  468. },
  469. open: function(){
  470. core.panel.open(elements.configPanel || core.config.createPanel());
  471. },
  472. close: function(){
  473. core.panel.close(elements.configPanel);
  474. },
  475. toggle: function(){
  476. core.panel.toggle(elements.configPanel || core.config.createPanel(), core.config.open, core.config.close);
  477. },
  478. createPanel: function(){
  479. let configPanel = elements.configPanel = createElement(core.html.configPanel());
  480. configPanel.querySelector('button.cancel').addEventListener('click', core.config.close);
  481. configPanel.querySelector('button.save').addEventListener('click', function(e){
  482. let inputs = configPanel.querySelectorAll('input'), new_configs = {};
  483. for(let i = 0, input; input = inputs[i]; i++){
  484. switch(CONFIGS[input.name].TYPE){
  485. case('bool'):
  486. new_configs[input.name] = (input.checked) ? 1 : 0;
  487. break;
  488. case('object'):
  489. if(!new_configs[input.name]) new_configs[input.name] = {};
  490. new_configs[input.name][input.value] = (input.checked) ? 1 : 0;
  491. break;
  492. default:
  493. new_configs[input.name] = input.value;
  494. break;
  495. }
  496. }
  497. core.config.save(new_configs);
  498. core.config.close();
  499. /* 新しい設定値で再スタイリング */
  500. core.addStyle();
  501. }, true);
  502. configPanel.querySelector('input[name="show_next"]').addEventListener('click', function(e){
  503. let selectors = ['next_at_end', 'next_countdown'];
  504. selectors.forEach(selector => {
  505. let sub = configPanel.querySelector(`input[name="${selector}"]`);
  506. sub.disabled = !sub.disabled;
  507. sub.parentNode.parentNode.classList.toggle('disabled');
  508. });
  509. }, true);
  510. configPanel.keyAssigns = {
  511. 'Escape': core.config.close,
  512. };
  513. return configPanel;
  514. },
  515. },
  516. panel: {
  517. createPanels: function(){
  518. if(elements.panels) return;
  519. let panels = elements.panels = createElement(core.html.panels());
  520. panels.dataset.panels = 0;
  521. document.body.appendChild(panels);
  522. /* Escapeキーで閉じるなど */
  523. window.addEventListener('keydown', function(e){
  524. if(['input', 'textarea'].includes(document.activeElement.localName)) return;
  525. Array.from(panels.children).forEach((p) => {
  526. if(p.classList.contains('hidden')) return;
  527. /* 表示中のパネルに対するキーアサインを確認 */
  528. if(p.keyAssigns){
  529. if(p.keyAssigns[e.key]){
  530. e.preventDefault();
  531. return p.keyAssigns[e.key]();/*単一キーなら簡単に処理*/
  532. }
  533. for(let i = 0, assigns = Object.keys(p.keyAssigns); assigns[i]; i++){
  534. let keys = assigns[i].split('+');/*プラス区切りで指定*/
  535. if(!['altKey','shiftKey','ctrlKey','metaKey'].every(
  536. (m) => (e[m] && keys.includes(m)) || (!e[m] && !keys.includes(m)))
  537. ) return;/*修飾キーの一致を確認*/
  538. if(keys[keys.length - 1] === e.key){
  539. e.preventDefault();
  540. return p.keyAssigns[assigns[i]]();/*最後が通常キー*/
  541. }
  542. }
  543. }
  544. });
  545. }, true);
  546. },
  547. open: function(panel){
  548. let panels = elements.panels;
  549. if(!panel.isConnected){
  550. panel.classList.add('hidden');
  551. panels.insertBefore(panel, Array.from(panels.children).find((p) => panel.dataset.order < p.dataset.order));
  552. }
  553. panels.dataset.panels = parseInt(panels.dataset.panels) + 1;
  554. animate(function(){panel.classList.remove('hidden')});
  555. },
  556. show: function(panel){
  557. core.panel.open(panel);
  558. },
  559. hide: function(panel, close = false){
  560. if(panel.classList.contains('hidden')) return;/*連続Escなどによる二重起動を避ける*/
  561. let panels = elements.panels;
  562. panel.classList.add('hidden');
  563. panel.addEventListener('transitionend', function(e){
  564. panels.dataset.panels = parseInt(panels.dataset.panels) - 1;
  565. if(close){
  566. panels.removeChild(panel);
  567. elements[panel.dataset.name] = null;
  568. }
  569. }, {once: true});
  570. },
  571. close: function(panel){
  572. core.panel.hide(panel, true);
  573. },
  574. toggle: function(panel, open, close){
  575. if(!panel.isConnected || panel.classList.contains('hidden')) open();
  576. else close();
  577. },
  578. },
  579. addStyle: function(name = 'style'){
  580. let style = createElement(core.html[name]());
  581. document.head.appendChild(style);
  582. if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
  583. elements[name] = style;
  584. },
  585. html: {
  586. configButton: () => `
  587. <button id="${SCRIPTNAME}-config-button" title="${SCRIPTNAME} 設定"><svg width="20" height="20" role="img"><use xlink:href="/images/icons/config.svg#svg-body"></use></svg></button>
  588. `,
  589. configPanel: () => `
  590. <div class="panel" id="${SCRIPTNAME}-config-panel" data-name="configPanel" data-order="1">
  591. <h1>${SCRIPTNAME}設定</h1>
  592. <fieldset>
  593. <legend>ビデオ再生</legend>
  594. <p><label>自動で再生を開始する: <input type="checkbox" name="auto_play" value="${configs.auto_play}" ${configs.auto_play ? 'checked' : ''}></label></p>
  595. <p><label>ブラウザ全画面かどうかを記憶する: <input type="checkbox" name="keep_screen" value="${configs.keep_screen}" ${configs.keep_screen ? 'checked' : ''}></label></p>
  596. <p><label>再生速度を記憶する: <input type="checkbox" name="keep_speed" value="${configs.keep_speed}" ${configs.keep_speed ? 'checked' : ''}></label></p>
  597. <legend>次のエピソードへの移動</legend>
  598. <p><label>次のエピソードへの移動ボタンを出す: <input type="checkbox" name="show_next" value="${configs.show_next}" ${configs.show_next ? 'checked' : ''}></label></p>
  599. <p class="sub ${configs.show_next ? '' : 'disabled'}"><label>最後まで再生し終えたときだけ出す: <input type="checkbox" name="next_at_end" ${configs.next_at_end ? 'checked' : ''} ${configs.show_next ? '' : 'disabled'}></label></p>
  600. <p class="sub ${configs.show_next ? '' : 'disabled'}"><label>カウントダウンして自動移動する: <input type="checkbox" name="next_countdown" ${configs.next_countdown ? 'checked' : ''} ${configs.show_next ? '' : 'disabled'}></label></p>
  601. </fieldset>
  602. <p class="buttons"><button class="cancel">キャンセル</button><button class="save primary">保存</button></p>
  603. </div>
  604. `,
  605. panels: () => `
  606. <div class="panels" id="${SCRIPTNAME}-panels"></div>
  607. `,
  608. style: () => `
  609. <style type="text/css">
  610. /* panel_zIndex: ${configs.panel_zIndex = 100} */
  611. /* nav_transition: ${configs.nav_transition = `250ms ${EASING}`} */
  612. /* ウィンドウサイズ可変対応 */
  613. body{
  614. overflow-x: hidden;/*横スクロールバーを出さないように*/
  615. }
  616. [data-selector="player"]{
  617. max-width: 100vw;/*小さいウィンドウにもできるだけビデオサイズを追随させる*/
  618. }
  619. /* コントローラUI */
  620. [data-selector="controlBackground"]{
  621. background: linear-gradient(transparent, rgba(0,0,0,.1), rgba(0,0,0,.3), rgba(0,0,0,.6));/*影を薄めつつ立ち上がりも優しく*/
  622. }
  623. /* 現在時刻 */
  624. [data-selector="currentTime"]{
  625. display: none;
  626. }
  627. [data-selector="replacedCurrentTime"]{
  628. }
  629. /* 設定ボタン */
  630. #${SCRIPTNAME}-config-button{
  631. fill: white;
  632. animation: ${SCRIPTNAME}-show 250ms 1;
  633. }
  634. @keyframes ${SCRIPTNAME}-show{
  635. from{
  636. opacity: 0;
  637. }
  638. to{
  639. opacity: 1;
  640. }
  641. }
  642. /* 再生速度ボタン */
  643. [data-selector="playbackRateButton"] > div > div{
  644. padding: 0 10px 5px;/*スライダの表示判定を広くしてあげる*/
  645. margin-bottom: -5px;
  646. box-sizing: content-box;
  647. }
  648. /* ボリュームボタン(CSS指定が異なる(!)リアルタイム放送に影響を与えないように注意) */
  649. [data-selector="player"] [data-selector="VolumeController"] > div{
  650. width: 100%;/*スライダの表示判定を広くしてあげる*/
  651. height: 100%;
  652. }
  653. [data-selector="player"] [data-selector="VolumeController"] > div > button{
  654. position: relative;
  655. top: 50%;
  656. transform: translate(0, -50%);
  657. }
  658. [data-selector="player"] [data-selector="VolumeController"] [class$="slider-container"]/*スライダ*/{
  659. padding: 0 10px;/*クリック判定範囲を広くしてあげる*/
  660. left: 50%;
  661. transform: translate(-50%, -100%);
  662. }
  663. [data-selector="player"] [data-selector="VolumeController"] button > svg{
  664. vertical-align: bottom;/*アベマのわずかなズレを修正*/
  665. }
  666. /* 次のエピソードへの自動移動ボタン */
  667. [data-selector="nextButton"]{
  668. display: ${configs.show_next ? 'block' : 'none'};
  669. width: 0 !important;/*アベマ公式はここの固定幅で表示制御しているが*/
  670. }
  671. [data-selector="nextButton"][data-shown="true"]{/*アベマ公式を上書きして表示させる*/
  672. overflow: visible;
  673. }
  674. [data-selector="nextButton"][data-shown="true"] > div{
  675. transform: translateX(-100%);/*固定幅に依存せずここで表示制御する*/
  676. opacity: 1;
  677. }
  678. [data-selector="nextButtonCount"],
  679. [data-selector="nextButtonCountPie"]{
  680. visibility: ${configs.next_countdown ? 'visible' : 'hidden'};
  681. }
  682. /* パネル共通 */
  683. #${SCRIPTNAME}-panels{
  684. position: absolute;
  685. width: 100%;
  686. height: 100%;
  687. top: 0;
  688. left: 0;
  689. overflow: hidden;
  690. pointer-events: none;
  691. }
  692. #${SCRIPTNAME}-panels div.panel{
  693. position: absolute;
  694. max-height: 100%;/*小さなウィンドウに対応*/
  695. overflow: auto;
  696. left: 50%;
  697. bottom: 50%;
  698. transform: translate(-50%, 50%);
  699. z-index: ${configs.panel_zIndex};
  700. background: rgba(0,0,0,.75);
  701. transition: ${configs.nav_transition};
  702. padding: 5px 0;
  703. pointer-events: auto;
  704. }
  705. #${SCRIPTNAME}-panels div.panel.hidden{
  706. bottom: 0;
  707. transform: translate(-50%, 100%) !important;
  708. }
  709. #${SCRIPTNAME}-panels div.panel.hidden *{
  710. animation: none !important;/*CPU負荷軽減*/
  711. }
  712. #${SCRIPTNAME}-panels h1,
  713. #${SCRIPTNAME}-panels h2,
  714. #${SCRIPTNAME}-panels h3,
  715. #${SCRIPTNAME}-panels h4,
  716. #${SCRIPTNAME}-panels legend,
  717. #${SCRIPTNAME}-panels li,
  718. #${SCRIPTNAME}-panels dl,
  719. #${SCRIPTNAME}-panels code,
  720. #${SCRIPTNAME}-panels p{
  721. color: rgba(255,255,255,1);
  722. font-size: 14px;
  723. padding: 2px 10px;
  724. line-height: 1.4;
  725. }
  726. #${SCRIPTNAME}-panels header{
  727. display: flex;
  728. }
  729. #${SCRIPTNAME}-panels header h1{
  730. flex: 1;
  731. }
  732. #${SCRIPTNAME}-panels div.panel > p.buttons{
  733. text-align: right;
  734. padding: 5px 10px;
  735. }
  736. #${SCRIPTNAME}-panels div.panel > p.buttons button{
  737. width: 120px;
  738. padding: 5px 10px;
  739. margin-left: 10px;
  740. border-radius: 5px;
  741. color: rgba(255,255,255,1);
  742. background: rgba(64,64,64,1);
  743. border: 1px solid rgba(255,255,255,1);
  744. }
  745. #${SCRIPTNAME}-panels div.panel > p.buttons button.primary{
  746. font-weight: bold;
  747. background: rgba(0,0,0,1);
  748. }
  749. #${SCRIPTNAME}-panels div.panel > p.buttons button:hover,
  750. #${SCRIPTNAME}-panels div.panel > p.buttons button:focus{
  751. background: rgba(128,128,128,.875);
  752. }
  753. #${SCRIPTNAME}-panels .template{
  754. display: none !important;
  755. }
  756. #${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(1){
  757. transform: translate(-100%, 50%);
  758. }
  759. #${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(2){
  760. transform: translate(0%, 50%);
  761. }
  762. #${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(1){
  763. transform: translate(-150%, 50%);
  764. }
  765. #${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(3){
  766. transform: translate(50%, 50%);
  767. }
  768. /* 設定パネル */
  769. #${SCRIPTNAME}-config-panel{
  770. width: 360px;
  771. }
  772. #${SCRIPTNAME}-config-panel fieldset p{
  773. padding-left: calc(10px + 1em);
  774. }
  775. #${SCRIPTNAME}-config-panel fieldset p:not(.note):hover{
  776. background: rgba(255,255,255,.25);
  777. }
  778. #${SCRIPTNAME}-config-panel fieldset p.disabled{
  779. opacity: .5;
  780. }
  781. #${SCRIPTNAME}-config-panel fieldset .sub{
  782. padding-left: calc(10px + 2em);
  783. }
  784. #${SCRIPTNAME}-config-panel label{
  785. display: block;
  786. }
  787. #${SCRIPTNAME}-config-panel input{
  788. width: 80px;
  789. height: 20px;
  790. position: absolute;
  791. right: 10px;
  792. }
  793. #${SCRIPTNAME}-config-panel input[type="text"]{
  794. width: 160px;
  795. }
  796. #${SCRIPTNAME}-config-panel input[type="text"]:invalid{
  797. border: 1px solid rgba(255, 0, 0, 1);
  798. background: rgba(255, 0, 0, .5);
  799. }
  800. #${SCRIPTNAME}-config-panel p.note{
  801. color: gray;
  802. font-size: 75%;
  803. padding-left: calc(10px + 1.33em);/*75%ぶん割り戻す*/
  804. }
  805. </style>
  806. `,
  807. },
  808. };
  809. const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
  810. const getComputedStyle = window.getComputedStyle, fetch = window.fetch;
  811. if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  812. class Storage{
  813. static key(key){
  814. return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
  815. }
  816. static save(key, value, expire = null){
  817. key = Storage.key(key);
  818. localStorage[key] = JSON.stringify({
  819. value: value,
  820. saved: Date.now(),
  821. expire: expire,
  822. });
  823. }
  824. static read(key){
  825. key = Storage.key(key);
  826. if(localStorage[key] === undefined) return undefined;
  827. let data = JSON.parse(localStorage[key]);
  828. if(data.value === undefined) return data;
  829. if(data.expire === undefined) return data;
  830. if(data.expire === null) return data.value;
  831. if(data.expire < Date.now()) return localStorage.removeItem(key);
  832. return data.value;
  833. }
  834. static delete(key){
  835. key = Storage.key(key);
  836. delete localStorage.removeItem(key);
  837. }
  838. static saved(key){
  839. key = Storage.key(key);
  840. if(localStorage[key] === undefined) return undefined;
  841. let data = JSON.parse(localStorage[key]);
  842. if(data.saved) return data.saved;
  843. else return undefined;
  844. }
  845. }
  846. const $ = function(s){return document.querySelector(s)};
  847. const $$ = function(s){return document.querySelectorAll(s)};
  848. const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  849. const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){
  850. let observer = new MutationObserver(callback.bind(element));
  851. observer.observe(element, options);
  852. return observer;
  853. };
  854. const createElement = function(html = '<span></span>'){
  855. let outer = document.createElement('div');
  856. outer.innerHTML = html;
  857. return outer.firstElementChild;
  858. };
  859. const log = function(){
  860. if(!DEBUG) return;
  861. let l = log.last = log.now || new Date(), n = log.now = new Date();
  862. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  863. //console.log(error.stack);
  864. console.log(
  865. SCRIPTNAME + ':',
  866. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  867. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  868. /* :00 */ ':' + line,
  869. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  870. /* caller */ (callers[1] || '') + '()',
  871. ...arguments
  872. );
  873. };
  874. log.formats = [{
  875. name: 'Firefox Scratchpad',
  876. detector: /MARKER@Scratchpad/,
  877. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  878. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  879. }, {
  880. name: 'Firefox Console',
  881. detector: /MARKER@debugger/,
  882. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  883. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  884. }, {
  885. name: 'Firefox Greasemonkey 3',
  886. detector: /\/gm_scripts\//,
  887. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  888. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  889. }, {
  890. name: 'Firefox Greasemonkey 4+',
  891. detector: /MARKER@user-script:/,
  892. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  893. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  894. }, {
  895. name: 'Firefox Tampermonkey',
  896. detector: /MARKER@moz-extension:/,
  897. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  898. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  899. }, {
  900. name: 'Chrome Console',
  901. detector: /at MARKER \(<anonymous>/,
  902. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  903. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  904. }, {
  905. name: 'Chrome Tampermonkey',
  906. detector: /at MARKER \((userscript\.html|chrome-extension:)/,
  907. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 6,
  908. getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|chrome-extension:))/gm),
  909. }, {
  910. name: 'Edge Console',
  911. detector: /at MARKER \(eval/,
  912. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  913. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  914. }, {
  915. name: 'Edge Tampermonkey',
  916. detector: /at MARKER \(Function/,
  917. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  918. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  919. }, {
  920. name: 'Safari',
  921. detector: /^MARKER$/m,
  922. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  923. getCallers: (e) => e.stack.split('\n'),
  924. }, {
  925. name: 'Default',
  926. detector: /./,
  927. getLine: (e) => 0,
  928. getCallers: (e) => [],
  929. }];
  930. log.format = log.formats.find(function MARKER(f){
  931. if(!f.detector.test(new Error().stack)) return false;
  932. //console.log('////', f.name, 'wants', 85, '\n' + new Error().stack);
  933. return true;
  934. });
  935. const time = function(label){
  936. const BAR = '|', TOTAL = 100;
  937. switch(true){
  938. case(label === undefined):/* time() to output total */
  939. let total = 0;
  940. Object.keys(time.records).forEach((label) => total += time.records[label].total);
  941. Object.keys(time.records).forEach((label) => {
  942. console.log(
  943. BAR.repeat((time.records[label].total / total) * TOTAL),
  944. label + ':',
  945. (time.records[label].total).toFixed(3) + 'ms',
  946. '(' + time.records[label].count + ')',
  947. );
  948. });
  949. time.records = {};
  950. break;
  951. case(!time.records[label]):/* time('label') to start the record */
  952. time.records[label] = {count: 0, from: performance.now(), total: 0};
  953. break;
  954. case(time.records[label].from === null):/* time('label') to re-start the lap */
  955. time.records[label].from = performance.now();
  956. break;
  957. case(0 < time.records[label].from):/* time('label') to add lap time to the record */
  958. time.records[label].total += performance.now() - time.records[label].from;
  959. time.records[label].from = null;
  960. time.records[label].count += 1;
  961. break;
  962. }
  963. };
  964. time.records = {};
  965. core.initialize();
  966. if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
  967. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址