AbemaTV のビデオ視聴を快適にします。
// ==UserScript== // @name AbemaTV Video Assistant // @namespace knoa.jp // @description AbemaTV のビデオ視聴を快適にします。 // @include https://abema.tv/* // @version 1.0.3 // @grant none // ==/UserScript== // console.log('AbemaTV? => hireMe()'); (function(){ const SCRIPTNAME = 'VideoAssistant'; const DEBUG = false;/* [update] 1.0.3 再生速度が「次のエピソード」への移動でリセットされてしまうバグを解消。(またしてもアベマ公式の謎仕様に起因(。◟‸◞。✿)) [bug] [to do] video以外のページならgoneでcss外すか。 時刻ズレが気持ち悪いので全体時間も上書きしよう。 [to research] 無音時に限り音量が記憶されない公式のバグ CMで一瞬ヘッダがでるのは公式の仕様 [possible] 上部ナビゲーション番組表の隣にマイリスト昇格 ビデオ視聴ページ 戻る進む時に画面##にインジケータ コメント数ヒートマップ ? マイリストページ 一覧性向上や分類など 「プレミアムなら」を明示 期限切れをすべて削除するボタン ビデオトップページ removed_genre: {TYPE: 'object', DEFAULT: {}},*(削除したジャンル)* removed_heading: {TYPE: 'object', DEFAULT: {}},*(削除した見出し)* [requests] [not to do] :has疑似セレクタ実装はまだまだ先になりそう */ if(window === top && console.time) console.time(SCRIPTNAME); const CONFIGS = { /* ビデオ再生 */ auto_play: {TYPE: 'bool', DEFAULT: 1 },/*自動で再生を開始する*/ keep_screen: {TYPE: 'bool', DEFAULT: 0 },/*ブラウザ全画面かどうかを記憶する*/ keep_speed: {TYPE: 'bool', DEFAULT: 0 },/*再生速度を記憶する*/ /* 次のエピソードへの移動 */ show_next: {TYPE: 'bool', DEFAULT: 1 },/*次のエピソードへの移動ボタンを出す*/ next_at_end: {TYPE: 'bool', DEFAULT: 0 },/* ビデオの最後まで再生してから出す*/ next_countdown: {TYPE: 'bool', DEFAULT: 1 },/* カウントダウンして自動移動する*/ }; const URLS = { CHANNELS: 'https://abema.tv/channels/',/*見逃し番組視聴ページ(未来や期限切れも含む)*/ VIDEO: 'https://abema.tv/video/episode/',/*ビデオ視聴ページ(期限切れも含む)*/ }; const EASING = 'cubic-bezier(0,.75,.5,1)';/*主にナビゲーションのアニメーション用*/ const RETRY = 10;/*必要な要素が見つからずあきらめるまでの試行回数*/ let site = { videoTargets: { video: () => $('.com-a-Video__container video[src]'),/*CMとは区別する*/ }, adVideoTargets: { adContainer: () => $('#videoAdContainer'), adVideos: () => $$('#videoAdContainer video'),/*CM*/ adVideoController: () => $('.com-video_ad-VideoAdControlBar__controls'), }, elementTargets: { player: () => $('.c-tv-SlotPlayerContainer') || $('.c-vod-PlayerContainer-wrapper'),/*タイムシフトまたはビデオ*/ controlBackground: () => $('.com-vod-VideoControlBar__bg'), playButton: () => $('.com-vod-VideoControlBar__play-handle'), currentTime: () => $('.com-vod-VODTime > span > time'), playbackRateButton: () => $('.com-vod-VideoControlBar__playback-rate'), VolumeController: () => $('.com-vod-VideoControlBar__volume'), }, nextButtonTargets: {/*次のエピソードへ*/ nextButton: () => $('.com-vod-VODNextProgramInfo'), nextButtonAnchor: () => $('.com-vod-VODNextProgramInfo a[href]'), nextButtonCount: () => $('.com-video-MediaInfoCard__count'), nextButtonCountPie: () => $('.com-video-MediaInfoCard__thumbnail > span'), nextButtonCancel: () => $('.com-vod-VODNextProgramInfo__close-button'), }, screenButtonTargets: {/*画面サイズボタン*/ fullScreenInBrowserButton: () => $('.com-vod-VideoControlBar__screen-controller'), fullScreenButton: () => $('.com-vod-VideoControlBar__screen-controller + .com-vod-VideoControlBar__screen-controller'), }, screenButtonOnAdTargets: {/*CM中の画面サイズボタン*/ fullScreenInBrowserButtonOnAd: () => $('.com-video_ad-VideoAdControlBar__button'), fullScreenButtonOnAd: () => $('.com-video_ad-VideoAdControlBar__button + .com-video_ad-VideoAdControlBar__button'), }, get: { playbackImage: () => $('.com-vod-VODScreen-playback-image'),/*再生/停止のオーバーレイインジケータ*/ playbackIcon: (button) => button.querySelector('use[*|href^="/images/icons/playback.svg"]'),/*再生/停止ボタンの再生アイコン*/ currentPlaybackRate: () => $('.com-a-RadioButton--checked input[name="vod-setting-playbackRate"]'),/*現在選択中の再生速度*/ targetPlaybackRate: (value) => $(`input[name="vod-setting-playbackRate"][value="${value}"]`),/*目的の再生速度*/ miniScreenInBrowserIcon: (button) => button.querySelector('use[*|href^="/images/icons/mini_screen_in_browser.svg"]'), fullScreenInBrowserIcon: (button) => button.querySelector('use[*|href^="/images/icons/full_screen_in_browser.svg"]'), miniScreenIcon: (button) => button.querySelector('use[*|href^="/images/icons/mini_screen.svg"]'),/*元の小画面または中画面に戻る*/ fullScreenIcon: (button) => button.querySelector('use[*|href^="/images/icons/full_screen.svg"]'), nextProgramThumbnail: (button) => button.querySelector('a img[alt]'), }, }; let elements = {}, storages = {}, configs = {}, timers = {}; let core = { initialize: function(){ html = document.documentElement; core.config.read(); core.addStyle(); core.panel.createPanels(); core.listenUserActions(); core.checkUrl(); }, checkUrl: function(){ let previousUrl = ''; const videoPages = [URLS.CHANNELS, URLS.VIDEO]; const isVideoPage = () => videoPages.some(url => location.href.startsWith(url)); const wasVideoPage = () => videoPages.some(url => previousUrl.startsWith(url)); setInterval(function(){ switch(true){ case(location.href === previousUrl): return;/*URLが変わってない*/ case(isVideoPage()):/*ビデオ視聴ページ*/ core.videoReady();/*ビデオ視聴ページに来た*/ break; default:/*ビデオ視聴ページではない*/ break; } previousUrl = location.href; }, 1000); }, videoReady: function(){ let previousSrc = (elements.video) ? elements.video.src : null; core.getTargets(site.videoTargets, RETRY).then(() => { if(elements.video.src === previousSrc) setTimeout(core.videoReady, 1000);/*まだDOMが差し替わってない*/ log("I'm ready for video."); html.classList.add(SCRIPTNAME); core.setAutoPlay(); core.adVideosReady(); core.elementsReady(); }); }, adVideosReady: function(){ core.getTargets(site.adVideoTargets, RETRY).then(() => { log("I'm ready for ad videos."); core.keepScreen(); core.makeAdsPausable(); core.waitForAdEnded(); }); }, elementsReady: function(){ core.getTargets(site.elementTargets, RETRY).then(() => { log("I'm ready for elements."); core.config.createButton(); core.replaceVideoTime(); core.keepScreen(); core.keepSpeed(); core.alterNextButton(); core.modifyPlayButton(); }); }, getTargets: function(targets, retry = 0){ const get = function(resolve, reject, retry){ for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){ let selected = targets[key](); if(selected){ if(selected.length) selected.forEach((s) => s.dataset.selector = key); else selected.dataset.selector = key; elements[key] = selected; }else{ if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`)); log(`Not found: ${key}, retrying... (left ${retry})`); return setTimeout(get, 1000, resolve, reject, retry); } } resolve(); }; return new Promise(function(resolve, reject){ get(resolve, reject, retry); }); }, listenUserActions: function(){ document.addEventListener('fullscreenchange', function(e){ if(document.fullscreenElement){/*フルスクリーンなら*/ document.fullscreenElement.appendChild(elements.panels); }else{ document.body.appendChild(elements.panels); } }); }, makeAdsPausable: function(){ let adContainer = elements.adContainer, adVideoController = elements.adVideoController, cuurentAd = undefined; const toggle = function(e){ if(!cuurentAd){ cuurentAd = Array.from(elements.adVideos).find((v) => !v.paused);/*elements.にしないとlistener登録した時点の古いDOMを引きずる*/ if(cuurentAd) cuurentAd.pause(); }else{ cuurentAd.play(); cuurentAd = undefined; } }; if(!adContainer.isListeningClick){/*要素ごとに1度だけ*/ adContainer.isListeningClick = true; adContainer.addEventListener('click', function(e){ if(adVideoController.contains(e.target)) return; toggle(e); }, {capture: true}); } if(!core.makeAdsPausable.isListeningKeydown){/*スクリプトごとに1度だけ*/ core.makeAdsPausable.isListeningKeydown = true; if(html.classList.contains('ShortcutKeyController')){ window.addEventListener('keydown', function(e){ if(['input', 'textarea'].includes(document.activeElement.localName)) return; if(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey) return; if(['k', ' ', 'Enter'].includes(e.key)) toggle(e); }, {capture: true}); } } }, waitForAdEnded: function(){ let adVideos = elements.adVideos; adVideos.forEach((v) => v.addEventListener('ended', core.elementsReady)); }, replaceVideoTime: function(){ let video = elements.video, currentTime = elements.currentTime, replacedCurrentTime = currentTime.cloneNode(true); const secondsToTime = function(seconds){ let floor = Math.floor, zero = (s) => s.toString().padStart(2, '0'); let h = floor(seconds/3600), m = floor(seconds/60)%60, s = floor(seconds%60); if(h) return h + ':' + zero(m) + ':' + zero(s); else return m + ':' + zero(s); }; const tiktok = function(e){/*なおアベマ公式はdelay調整無しで1秒ごとに四捨五入*/ let delay = ((1 - video.currentTime%1) / video.playbackRate)*1000;/*次の秒になるまでの時間(足りなくてももう一度呼ばれて問題ない)*/ if(0.5 < delay) replacedCurrentTime.textContent = secondsToTime(video.currentTime); clearInterval(timers.tiktok), timers.tiktok = setTimeout(tiktok, delay); }; /* 独自要素に置き換える */ replacedCurrentTime.dataset.selector = 'replacedCurrentTime'; currentTime.parentNode.insertBefore(replacedCurrentTime, currentTime); /* 再生中に独自要素を更新し続ける */ if(!video.paused) tiktok(); video.addEventListener('play', tiktok); video.addEventListener('pause', function(e){ clearInterval(timers.tiktok); }); video.addEventListener('seeked', function(e){ replacedCurrentTime.textContent = secondsToTime(video.currentTime); }); }, setAutoPlay: function(){ let video = elements.video, nextButton = elements.nextButton || createElement(), playbackImage = site.get.playbackImage() || createElement(); let conditions = [[/*停止状態にしたい条件*/ (configs.auto_play === 0),/*自動で再生を開始しない*/ (location.href.endsWith('?next=true') === false),/*1つめのエピソードである*/ ], [ (configs.auto_play === 0),/*自動で再生を開始しない*/ (nextButton.videoWasPaused === true),/*ビデオが停止中であった*/ (nextButton.clicked === true),/*リンクをみずからクリック(カウントダウンではない)*/ ]]; const pause = function(e){ video.pause(); setTimeout(function(){playbackImage.style.visibility = ''}, 1500);/*1000で足りないこともあったので*/ video.removeEventListener('canplay', pause); }; if(conditions.some((set) => set.every((c) => (c === true)))){ playbackImage.style.visibility = 'hidden'; video.addEventListener('canplay', pause);/*一瞬音声が流れてしまうこともある*/ } /* setAutoPlayが呼ばれる(新しい番組)ごとにリセット */ nextButton.videoWasPaused = false; nextButton.clicked = false; }, modifyPlayButton: function(){ let video = elements.video, playButton = elements.playButton; let conditions = [/*アイコンを修正すべき条件*/ (configs.auto_play === 0),/*自動で再生を開始しない*/ (video.paused === true),/*停止している*/ (site.get.playbackIcon(playButton) === null),/*にもかかわらずアイコンが停止状態を示していない!!*/ ]; if(conditions.every((c) => c === true)) playButton.click();/*アイコンだけ停止状態になる*/ }, keepScreen: function(){ /* fullScreenInBrowserButton(小画面と中画面のトグル) + fullScreenButton(全画面:DOM再取得が必要) */ Promise.race([ core.getTargets(site.screenButtonTargets, RETRY), core.getTargets(site.screenButtonOnAdTargets, RETRY), ]).then(() => { let video = elements.video; let fullScreenInBrowserButton = [elements.fullScreenInBrowserButton, elements.fullScreenInBrowserButtonOnAd].find((e) => e && e.isConnected); let fullScreenButton = [elements.fullScreenButton, elements.fullScreenButtonOnAd].find((e) => e && e.isConnected); const DELAY = 1000;/*画面サイズの変更にかかる時間を確保*/ const getCurrentScreen = function(){ switch(true){ case(site.get.fullScreenInBrowserIcon(fullScreenInBrowserButton) !== null): return 'miniScreenInBrowser';/*小画面*/ case(site.get.miniScreenInBrowserIcon(fullScreenInBrowserButton) !== null): return 'fullScreenInBrowser';/*中画面*/ case(site.get.miniScreenIcon(fullScreenButton) !== null): return 'fullScreen';/*全画面*/ } }; const saveScreen = function(e){ Storage.save('screen', getCurrentScreen()); }; const setScreen = function(){ switch(Storage.read('screen')){ case('miniScreenInBrowser'):/*小画面*/ return; case('fullScreenInBrowser'):/*中画面*/ return fullScreenInBrowserButton.click(); case('fullScreen'):/*全画面*/ return fullScreenButton.click();/*ブラウザ仕様につき機能しない*/ } }; if(!fullScreenInBrowserButton.isListeningClick){/*ボタンごとに1度だけ*/ fullScreenInBrowserButton.isListeningClick = true; fullScreenInBrowserButton.addEventListener('click', function(e){ setTimeout(saveScreen, DELAY); }); } if(!core.keepScreen.isListeningFullscreenchange){/*スクリプトごとに1度だけ*/ core.keepScreen.isListeningFullscreenchange = true; document.addEventListener('fullscreenchange', function(e){ setTimeout(saveScreen, DELAY); if(!document.fullscreenElement) setTimeout(core.keepScreen, DELAY);/*ボタンが差し替えられるので*/ }); } if(video.setScreen !== location.href){/*ビデオ内容ごとに1度だけ*/ video.setScreen = location.href; if(configs.keep_screen) setScreen();/*初回の視聴画面サイズを再現*/ } }); }, keepSpeed: function(){ let video = elements.video, playbackRateButton = elements.playbackRateButton; const getCurrentSpeed = function(){ return site.get.currentPlaybackRate().value || 1; }; const saveSpeed = function(e){ Storage.save('speed', getCurrentSpeed()); }; const setSpeed = function(){ let speed = Storage.read('speed') || 1; let input = site.get.targetPlaybackRate(speed); if(input) input.click();/*checkだけではアベマのDOMが反応しない*/ }; if(!playbackRateButton.isListeningRatechange){ playbackRateButton.isListeningRatechange = true; /* video要素へのratechangeイベントだと、次のエピソードに移ったときにアベマによる強制リセットで元に戻ってしまう */ playbackRateButton.addEventListener('click', function(e){ log(e); setTimeout(saveSpeed, 1000); }); } setSpeed(); }, alterNextButton: function(){ if(!location.href.startsWith(URLS.VIDEO)) return;/*次のエピソードが表示されるのはビデオのみ*/ core.getTargets(site.nextButtonTargets, RETRY).then(() => { let video = elements.video, nextButton = elements.nextButton, nextButtonAnchor = elements.nextButtonAnchor; let nextButtonCount = elements.nextButtonCount, nextButtonCancel = elements.nextButtonCancel; /* ビデオ終了時の独自カウントダウン(再生アイコンのアニメーションは割愛) */ const COUNT = 10; const countdown = function(){ let node = nextButtonCount.firstChild, count = COUNT; node.data = String(count); clearInterval(timers.countdown), timers.countdown = setInterval(function(){ node.data = String(--count); if(count === 0){ clearInterval(timers.countdown); nextButton.dataset.shown = 'false'; nextButtonAnchor.click(); } }, 1000); }; /* 番組終了間際に自動でボタンが出現する瞬間を検知する */ observe(nextButton, function(records){ if(nextButtonCancel.disabled) return;/*閉じた(つもり)のときは何もしない*/ if(video.ended){ nextButton.dataset.shown = 'true';/*閉じなくてもよい*/ if(configs.next_countdown) countdown();/*独自カウントダウン*/ return; } switch(true){ case(configs.show_next === 0):/*すぐ閉じて表示もさせない*/ nextButtonCancel.click(); nextButton.dataset.shown = 'false'; break; case(configs.next_at_end === 1):/*すぐ閉じて表示もさせない*/ nextButtonCancel.click(); nextButton.dataset.shown = 'false'; break; case(configs.next_countdown === 0):/*すぐ閉じてカウントダウンさせない*/ nextButtonCancel.click(); nextButton.dataset.shown = 'true'; break; default:/*閉じずにカウントダウン表示を続ける*/ nextButton.dataset.shown = 'true'; break; } }, {attributes: true, attributeFilter: ['class']});/*公式のclass変化のみを監視する*/ nextButton.classList.add('observing');/*すでにボタンが出ていた場合のきっかけにする*/ /* 次のエピソードの自動再生判定のためにボタンの実クリックを記録する */ nextButtonAnchor.addEventListener('click', function(e){ nextButton.videoWasPaused = video.paused; if(e.isTrusted){ nextButton.clicked = true; } }); /* ボタンの表示を独自に制御 */ nextButtonCancel.addEventListener('click', function(e){ if(e.isTrusted){ nextButton.dataset.shown = 'false';/*実クリックされたらもちろん消す*/ clearInterval(timers.countdown);/*独自カウントダウンしていたら止める*/ return; } setTimeout(function(){nextButtonCancel.disabled = false}, 1000);/*クリックはいつでもできる(正規のクリック後に上書き)*/ }); if(!video.isListeningSeeking){/*ビデオごとに1度だけ*/ video.isListeningSeeking = true; video.addEventListener('seeking', function(e){ let thumbnail = site.get.nextProgramThumbnail(nextButton); if(!thumbnail || thumbnail.alt === '') return;/*次のエピソードなし*/ if(nextButton.dataset.shown === 'true') nextButton.dataset.shown = 'false'; else if(video.currentTime + 1/*許容範囲*/ > video.duration) nextButton.dataset.shown = 'true'; }); } }); }, config: { read: function(){ /* 保存済みの設定を読む */ configs = Storage.read('configs') || {}; /* 未定義項目をデフォルト値で上書きしていく */ Object.keys(CONFIGS).forEach((key) => {if(configs[key] === undefined) configs[key] = CONFIGS[key].DEFAULT}); }, save: function(new_config){ configs = {};/*CONFIGSに含まれた設定値のみ保存する*/ /* CONFIGSを元に文字列を型評価して値を格#していく */ Object.keys(CONFIGS).forEach((key) => { /* 値がなければデフォルト値 */ if(new_config[key] === "") return configs[key] = CONFIGS[key].DEFAULT; switch(CONFIGS[key].TYPE){ case 'bool': configs[key] = (new_config[key]) ? 1 : 0; break; case 'int': configs[key] = parseInt(new_config[key]); break; case 'float': configs[key] = parseFloat(new_config[key]); break; default: configs[key] = new_config[key]; break; } }); Storage.save('configs', configs); }, createButton: function(){ if(elements.configButton && elements.configButton.isConnected) return; /* 再生速度ボタンを元に設定ボタンを追加する */ let configButton = elements.configButton = createElement(core.html.configButton()); configButton.className = elements.playbackRateButton.className; configButton.addEventListener('click', core.config.toggle); elements.playbackRateButton.parentNode.insertBefore(configButton, elements.playbackRateButton);/*元のDOM位置関係にできるだけ影響を与えない*/ }, open: function(){ core.panel.open(elements.configPanel || core.config.createPanel()); }, close: function(){ core.panel.close(elements.configPanel); }, toggle: function(){ core.panel.toggle(elements.configPanel || core.config.createPanel(), core.config.open, core.config.close); }, createPanel: function(){ let configPanel = elements.configPanel = createElement(core.html.configPanel()); configPanel.querySelector('button.cancel').addEventListener('click', core.config.close); configPanel.querySelector('button.save').addEventListener('click', function(e){ let inputs = configPanel.querySelectorAll('input'), new_configs = {}; for(let i = 0, input; input = inputs[i]; i++){ switch(CONFIGS[input.name].TYPE){ case('bool'): new_configs[input.name] = (input.checked) ? 1 : 0; break; case('object'): if(!new_configs[input.name]) new_configs[input.name] = {}; new_configs[input.name][input.value] = (input.checked) ? 1 : 0; break; default: new_configs[input.name] = input.value; break; } } core.config.save(new_configs); core.config.close(); /* 新しい設定値で再スタイリング */ core.addStyle(); }, true); configPanel.querySelector('input[name="show_next"]').addEventListener('click', function(e){ let selectors = ['next_at_end', 'next_countdown']; selectors.forEach(selector => { let sub = configPanel.querySelector(`input[name="${selector}"]`); sub.disabled = !sub.disabled; sub.parentNode.parentNode.classList.toggle('disabled'); }); }, true); configPanel.keyAssigns = { 'Escape': core.config.close, }; return configPanel; }, }, panel: { createPanels: function(){ if(elements.panels) return; let panels = elements.panels = createElement(core.html.panels()); panels.dataset.panels = 0; document.body.appendChild(panels); /* Escapeキーで閉じるなど */ window.addEventListener('keydown', function(e){ if(['input', 'textarea'].includes(document.activeElement.localName)) return; Array.from(panels.children).forEach((p) => { if(p.classList.contains('hidden')) return; /* 表示中のパネルに対するキーアサインを確認 */ if(p.keyAssigns){ if(p.keyAssigns[e.key]){ e.preventDefault(); return p.keyAssigns[e.key]();/*単一キーなら簡単に処理*/ } for(let i = 0, assigns = Object.keys(p.keyAssigns); assigns[i]; i++){ let keys = assigns[i].split('+');/*プラス区切りで指定*/ if(!['altKey','shiftKey','ctrlKey','metaKey'].every( (m) => (e[m] && keys.includes(m)) || (!e[m] && !keys.includes(m))) ) return;/*修飾キーの一致を確認*/ if(keys[keys.length - 1] === e.key){ e.preventDefault(); return p.keyAssigns[assigns[i]]();/*最後が通常キー*/ } } } }); }, true); }, open: function(panel){ let panels = elements.panels; if(!panel.isConnected){ panel.classList.add('hidden'); panels.insertBefore(panel, Array.from(panels.children).find((p) => panel.dataset.order < p.dataset.order)); } panels.dataset.panels = parseInt(panels.dataset.panels) + 1; animate(function(){panel.classList.remove('hidden')}); }, show: function(panel){ core.panel.open(panel); }, hide: function(panel, close = false){ if(panel.classList.contains('hidden')) return;/*連続Escなどによる二重起動を避ける*/ let panels = elements.panels; panel.classList.add('hidden'); panel.addEventListener('transitionend', function(e){ panels.dataset.panels = parseInt(panels.dataset.panels) - 1; if(close){ panels.removeChild(panel); elements[panel.dataset.name] = null; } }, {once: true}); }, close: function(panel){ core.panel.hide(panel, true); }, toggle: function(panel, open, close){ if(!panel.isConnected || panel.classList.contains('hidden')) open(); else close(); }, }, addStyle: function(name = 'style'){ let style = createElement(core.html[name]()); document.head.appendChild(style); if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]); elements[name] = style; }, html: { configButton: () => ` <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> `, configPanel: () => ` <div class="panel" id="${SCRIPTNAME}-config-panel" data-name="configPanel" data-order="1"> <h1>${SCRIPTNAME}設定</h1> <fieldset> <legend>ビデオ再生</legend> <p><label>自動で再生を開始する: <input type="checkbox" name="auto_play" value="${configs.auto_play}" ${configs.auto_play ? 'checked' : ''}></label></p> <p><label>ブラウザ全画面かどうかを記憶する: <input type="checkbox" name="keep_screen" value="${configs.keep_screen}" ${configs.keep_screen ? 'checked' : ''}></label></p> <p><label>再生速度を記憶する: <input type="checkbox" name="keep_speed" value="${configs.keep_speed}" ${configs.keep_speed ? 'checked' : ''}></label></p> <legend>次のエピソードへの移動</legend> <p><label>次のエピソードへの移動ボタンを出す: <input type="checkbox" name="show_next" value="${configs.show_next}" ${configs.show_next ? 'checked' : ''}></label></p> <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> <p class="sub ${configs.show_next ? '' : 'disabled'}"><label>カウントダウンして自動移動する: <input type="checkbox" name="next_countdown" ${configs.next_countdown ? 'checked' : ''} ${configs.show_next ? '' : 'disabled'}></label></p> </fieldset> <p class="buttons"><button class="cancel">キャンセル</button><button class="save primary">保存</button></p> </div> `, panels: () => ` <div class="panels" id="${SCRIPTNAME}-panels"></div> `, style: () => ` <style type="text/css"> /* panel_zIndex: ${configs.panel_zIndex = 100} */ /* nav_transition: ${configs.nav_transition = `250ms ${EASING}`} */ /* ウィンドウサイズ可変対応 */ body{ overflow-x: hidden;/*横スクロールバーを出さないように*/ } [data-selector="player"]{ max-width: 100vw;/*小さいウィンドウにもできるだけビデオサイズを追随させる*/ } /* コントローラUI */ [data-selector="controlBackground"]{ background: linear-gradient(transparent, rgba(0,0,0,.1), rgba(0,0,0,.3), rgba(0,0,0,.6));/*影を薄めつつ立ち上がりも優しく*/ } /* 現在時刻 */ [data-selector="currentTime"]{ display: none; } [data-selector="replacedCurrentTime"]{ } /* 設定ボタン */ #${SCRIPTNAME}-config-button{ fill: white; animation: ${SCRIPTNAME}-show 250ms 1; } @keyframes ${SCRIPTNAME}-show{ from{ opacity: 0; } to{ opacity: 1; } } /* 再生速度ボタン */ [data-selector="playbackRateButton"] > div > div{ padding: 0 10px 5px;/*スライダの表示判定を広くしてあげる*/ margin-bottom: -5px; box-sizing: content-box; } /* ボリュームボタン(CSS指定が異なる(!)リアルタイム放送に影響を与えないように注意) */ [data-selector="player"] [data-selector="VolumeController"] > div{ width: 100%;/*スライダの表示判定を広くしてあげる*/ height: 100%; } [data-selector="player"] [data-selector="VolumeController"] > div > button{ position: relative; top: 50%; transform: translate(0, -50%); } [data-selector="player"] [data-selector="VolumeController"] [class$="slider-container"]/*スライダ*/{ padding: 0 10px;/*クリック判定範囲を広くしてあげる*/ left: 50%; transform: translate(-50%, -100%); } [data-selector="player"] [data-selector="VolumeController"] button > svg{ vertical-align: bottom;/*アベマのわずかなズレを修正*/ } /* 次のエピソードへの自動移動ボタン */ [data-selector="nextButton"]{ display: ${configs.show_next ? 'block' : 'none'}; width: 0 !important;/*アベマ公式はここの固定幅で表示制御しているが*/ } [data-selector="nextButton"][data-shown="true"]{/*アベマ公式を上書きして表示させる*/ overflow: visible; } [data-selector="nextButton"][data-shown="true"] > div{ transform: translateX(-100%);/*固定幅に依存せずここで表示制御する*/ opacity: 1; } [data-selector="nextButtonCount"], [data-selector="nextButtonCountPie"]{ visibility: ${configs.next_countdown ? 'visible' : 'hidden'}; } /* パネル共通 */ #${SCRIPTNAME}-panels{ position: absolute; width: 100%; height: 100%; top: 0; left: 0; overflow: hidden; pointer-events: none; } #${SCRIPTNAME}-panels div.panel{ position: absolute; max-height: 100%;/*小さなウィンドウに対応*/ overflow: auto; left: 50%; bottom: 50%; transform: translate(-50%, 50%); z-index: ${configs.panel_zIndex}; background: rgba(0,0,0,.75); transition: ${configs.nav_transition}; padding: 5px 0; pointer-events: auto; } #${SCRIPTNAME}-panels div.panel.hidden{ bottom: 0; transform: translate(-50%, 100%) !important; } #${SCRIPTNAME}-panels div.panel.hidden *{ animation: none !important;/*CPU負荷軽減*/ } #${SCRIPTNAME}-panels h1, #${SCRIPTNAME}-panels h2, #${SCRIPTNAME}-panels h3, #${SCRIPTNAME}-panels h4, #${SCRIPTNAME}-panels legend, #${SCRIPTNAME}-panels li, #${SCRIPTNAME}-panels dl, #${SCRIPTNAME}-panels code, #${SCRIPTNAME}-panels p{ color: rgba(255,255,255,1); font-size: 14px; padding: 2px 10px; line-height: 1.4; } #${SCRIPTNAME}-panels header{ display: flex; } #${SCRIPTNAME}-panels header h1{ flex: 1; } #${SCRIPTNAME}-panels div.panel > p.buttons{ text-align: right; padding: 5px 10px; } #${SCRIPTNAME}-panels div.panel > p.buttons button{ width: 120px; padding: 5px 10px; margin-left: 10px; border-radius: 5px; color: rgba(255,255,255,1); background: rgba(64,64,64,1); border: 1px solid rgba(255,255,255,1); } #${SCRIPTNAME}-panels div.panel > p.buttons button.primary{ font-weight: bold; background: rgba(0,0,0,1); } #${SCRIPTNAME}-panels div.panel > p.buttons button:hover, #${SCRIPTNAME}-panels div.panel > p.buttons button:focus{ background: rgba(128,128,128,.875); } #${SCRIPTNAME}-panels .template{ display: none !important; } #${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(1){ transform: translate(-100%, 50%); } #${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(2){ transform: translate(0%, 50%); } #${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(1){ transform: translate(-150%, 50%); } #${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(3){ transform: translate(50%, 50%); } /* 設定パネル */ #${SCRIPTNAME}-config-panel{ width: 360px; } #${SCRIPTNAME}-config-panel fieldset p{ padding-left: calc(10px + 1em); } #${SCRIPTNAME}-config-panel fieldset p:not(.note):hover{ background: rgba(255,255,255,.25); } #${SCRIPTNAME}-config-panel fieldset p.disabled{ opacity: .5; } #${SCRIPTNAME}-config-panel fieldset .sub{ padding-left: calc(10px + 2em); } #${SCRIPTNAME}-config-panel label{ display: block; } #${SCRIPTNAME}-config-panel input{ width: 80px; height: 20px; position: absolute; right: 10px; } #${SCRIPTNAME}-config-panel input[type="text"]{ width: 160px; } #${SCRIPTNAME}-config-panel input[type="text"]:invalid{ border: 1px solid rgba(255, 0, 0, 1); background: rgba(255, 0, 0, .5); } #${SCRIPTNAME}-config-panel p.note{ color: gray; font-size: 75%; padding-left: calc(10px + 1.33em);/*75%ぶん割り戻す*/ } </style> `, }, }; const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame; const getComputedStyle = window.getComputedStyle, fetch = window.fetch; if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}}); class Storage{ static key(key){ return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key; } static save(key, value, expire = null){ key = Storage.key(key); localStorage[key] = JSON.stringify({ value: value, saved: Date.now(), expire: expire, }); } static read(key){ key = Storage.key(key); if(localStorage[key] === undefined) return undefined; let data = JSON.parse(localStorage[key]); if(data.value === undefined) return data; if(data.expire === undefined) return data; if(data.expire === null) return data.value; if(data.expire < Date.now()) return localStorage.removeItem(key); return data.value; } static delete(key){ key = Storage.key(key); delete localStorage.removeItem(key); } static saved(key){ key = Storage.key(key); if(localStorage[key] === undefined) return undefined; let data = JSON.parse(localStorage[key]); if(data.saved) return data.saved; else return undefined; } } const $ = function(s){return document.querySelector(s)}; const $$ = function(s){return document.querySelectorAll(s)}; const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))}; const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){ let observer = new MutationObserver(callback.bind(element)); observer.observe(element, options); return observer; }; const createElement = function(html = '<span></span>'){ let outer = document.createElement('div'); outer.innerHTML = html; return outer.firstElementChild; }; const log = function(){ if(!DEBUG) return; let l = log.last = log.now || new Date(), n = log.now = new Date(); let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error); //console.log(error.stack); console.log( SCRIPTNAME + ':', /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3), /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's', /* :00 */ ':' + line, /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') + /* caller */ (callers[1] || '') + '()', ...arguments ); }; log.formats = [{ name: 'Firefox Scratchpad', detector: /MARKER@Scratchpad/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Console', detector: /MARKER@debugger/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Greasemonkey 3', detector: /\/gm_scripts\//, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Greasemonkey 4+', detector: /MARKER@user-script:/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500, getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Tampermonkey', detector: /MARKER@moz-extension:/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6, getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Chrome Console', detector: /at MARKER \(<anonymous>/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm), }, { name: 'Chrome Tampermonkey', detector: /at MARKER \((userscript\.html|chrome-extension:)/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 6, getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|chrome-extension:))/gm), }, { name: 'Edge Console', detector: /at MARKER \(eval/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm), }, { name: 'Edge Tampermonkey', detector: /at MARKER \(Function/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4, getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm), }, { name: 'Safari', detector: /^MARKER$/m, getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/ getCallers: (e) => e.stack.split('\n'), }, { name: 'Default', detector: /./, getLine: (e) => 0, getCallers: (e) => [], }]; log.format = log.formats.find(function MARKER(f){ if(!f.detector.test(new Error().stack)) return false; //console.log('////', f.name, 'wants', 85, '\n' + new Error().stack); return true; }); const time = function(label){ const BAR = '|', TOTAL = 100; switch(true){ case(label === undefined):/* time() to output total */ let total = 0; Object.keys(time.records).forEach((label) => total += time.records[label].total); Object.keys(time.records).forEach((label) => { console.log( BAR.repeat((time.records[label].total / total) * TOTAL), label + ':', (time.records[label].total).toFixed(3) + 'ms', '(' + time.records[label].count + ')', ); }); time.records = {}; break; case(!time.records[label]):/* time('label') to start the record */ time.records[label] = {count: 0, from: performance.now(), total: 0}; break; case(time.records[label].from === null):/* time('label') to re-start the lap */ time.records[label].from = performance.now(); break; case(0 < time.records[label].from):/* time('label') to add lap time to the record */ time.records[label].total += performance.now() - time.records[label].from; time.records[label].from = null; time.records[label].count += 1; break; } }; time.records = {}; core.initialize(); if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME); })();