// ==UserScript== // @name AbemaTV Shortcut Key Controller // @namespace knoa.jp // @description AbemaTVでショートカットキーによる操作を可能にします。キー割り当てはYouTube準拠。 // @include https://abema.tv/* // @version 2.3.4 // @grant none // @downloadURL none // ==/UserScript== // console.log('AbemaTV? => hireMe()'); (function(){ const SCRIPTNAME = 'ShortcutKeyController'; const DEBUG = false;/* [update] 2.3.4 軽微な修正。 (programButton, Math, button:hover, panel) [bug] [to do] 音量、内部では小数点で保持するようにすればヌルヌルでも0.1刻みとかできるかも [possible] Escapeでcloser 単独起動時は特にほしい スクリプト単独起動時にもNでトグルとか?(せめてEscが効くといい) Keyboardで音量、スクロール文字の透明度? [requests] */ if(window === top && console.time) console.time(SCRIPTNAME); const DUMMY = document.createElement('span'); let site = { elements: { /* 共通 */ fullscreenButton: function(){let node = $('use[*|href*="mini_screen.svg"]') || $('use[*|href*="_screen.svg"]')/*タイムシフトのbuttonにaria-labelがないので*/; return node ? node.parentNode.parentNode : DUMMY;}, volumeSlider: function(){let node = $('button[aria-label^="音声"]'); return node ? node.previousSibling.firstElementChild.firstElementChild : DUMMY;}, muteButton: function(){let node = $('button[aria-label^="音声"]'); return node ? node : DUMMY;}, /* リアルタイム */ channelButton: function(){let node = $('button[aria-label="放送中の裏番組"]'); return node ? node : DUMMY;}, timetableButton: function(){let node = $('button[data-selector="TimetableViewerButton"]'); return node ? node : DUMMY;}, commentButton: function(){let node = $('use[*|href^="/images/icons/comment.svg"]'); return node ? node.parentNode.parentNode : DUMMY;}, programButton: function(){let node = $('button[aria-label^="フルスクリーン"]'); return (node) ? node.parentNode.lastElementChild.firstElementChild.firstElementChild : DUMMY;}, commentTextarea: function(){let node = $('textarea[placeholder="コメントを入力"]'); return node ? node : DUMMY;}, footer: function(){let node = $('button[aria-label^="フルスクリーン"]'); return node ? node.parentNode.parentNode : DUMMY;}, closer: function(){ /* チャンネル切り替えごとに変わる */ let loading = $('img[src="/images/misc/feed_loading.gif"]'); if(!loading) return; let button = loading.parentNode.parentNode.parentNode.parentNode.querySelectorAll('div > button')[1];/*アベマの構造にすごく依存する*/ return button; }, timetableHeaders: function(){let nodes = $$('#TimetableViewer-timetable-panel .channels > li > header'); return nodes ? nodes : DUMMY;}, /* タイムシフト */ stopper: function(){let node = $('use[*|href^="/images/icons/playback.svg"]'); return node ? node.parentNode.parentNode.nextElementSibling : DUMMY;}, playButton: function(){let node = $('use[*|href^="/images/icons/rewind_10.svg"]'); return node ? node.parentNode.parentNode.previousElementSibling : DUMMY;}, rewindButton: function(){let node = $('use[*|href^="/images/icons/rewind_10.svg"]'); return node ? node.parentNode.parentNode : DUMMY;}, advancesButton: function(){let node = $('use[*|href^="/images/icons/advances_30.svg"]'); return node ? node.parentNode.parentNode : DUMMY;}, }, isCommentPaneHidden: function(){ let form = $('form:not([role="search"])'); return (form) ? (form.parentNode.parentNode.getAttribute('aria-hidden') === 'true') : false; }, getCurrentVolume: function(){ let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect(); if(slider.dataset.volume === undefined){ return Math.round(100 * parseInt(slider.firstElementChild.style.height) / rect.height); }else{ return parseInt(slider.dataset.volume); } }, /* 整数による0-100の音量調整に対応する */ modifyVolume: function(e){ let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect(), volume = site.getCurrentVolume(); switch(e.deltaMode){ case(WheelEvent.DOM_DELTA_PIXEL):/*ヌルヌル*/ switch(true){ case(e.deltaY < -20): volume -= e.deltaY/20; break; case(e.deltaY < -1): volume += 1; break;/*最低1*/ case(e.deltaY < 0): volume += 0; break;/*微量なら0*/ case(e.deltaY < +1): volume -= 0; break;/*微量なら0*/ case(e.deltaY < +20): volume -= 1; break;/*最低1*/ default: volume -= e.deltaY/20; break; } break; case(WheelEvent.DOM_DELTA_LINE):/*カクカク*/ default: if(e.deltaY < 0){ switch(true){ case(volume < 10): volume += 1; break;/* 1単位*/ default: volume += 5; break;/* 5単位*/ } }else{ switch(true){ case(volume <= 10): volume -= 1; break;/* 1単位*/ default: volume -= 5; break;/* 5単位*/ } } break; } volume = between(0, Math.round(volume), 100);/*四捨五入して0-100に収める*/ let options = { clientX: rect.x + (rect.width/2), clientY: rect.y + (rect.height * (1 - volume/100)) + .5/*ゼロの時は確実にゼロにする*/, bubbles: true, }; slider.dispatchEvent(new MouseEvent('mousedown'/*clickだと効かない*/, options)); slider.dispatchEvent(new MouseEvent('mouseup', options)); slider.dataset.volume = volume; core.indicateVolume(volume); }, assign: function(e){ switch(true){ case(location.href.startsWith('https://abema.tv/now-on-air/')): return core.realtime(e); case(location.href.startsWith('https://abema.tv/channels/')): case(location.href.startsWith('https://abema.tv/video/watch/')): case(location.href.startsWith('https://abema.tv/video/episode/')): return core.timeshift(e); } }, }; let globals = {}, elements = {}, configs = {}, indicatorTimer; let core = { initialize: function(){ window.addEventListener('wheel', site.assign, true); window.addEventListener('keydown', site.assign, true); core.appendVolumeIndicator(); core.panel.createPanels(); core.addStyle(); }, /* 音量表示の準備 */ appendVolumeIndicator: function(e){ elements.indicator = createElement(core.html.volumeIndicator()); document.body.appendChild(elements.indicator); }, /* 音量表示 */ indicateVolume: function(volume){ elements.indicator.textContent = volume; elements.indicator.classList.add('active'); clearTimeout(indicatorTimer); indicatorTimer = setTimeout(function(){ elements.indicator.classList.remove('active'); }, 1000); }, /* リアルタイム */ realtime: function(e){ switch(true){ /* 音量 */ case(e.type === 'wheel' && Math.abs(e.deltaX) < Math.abs(e.deltaY)/*縦ホイールのみ*/): /* あらゆる場所でのイベントを拾ってwindow.addEventListenerで一括処理する代償をここで支払う */ let parents = [site.elements.closer(), site.elements.footer(), ...site.elements.timetableHeaders()]; for(let target = e.target; target; target = target.parentNode){ if(parents.includes(target)){ site.modifyVolume(e); return e.preventDefault(); } } return; /* コメント入力欄フォーカスを外す */ case(e.key === 'Escape'): if(document.activeElement === site.elements.commentTextarea()){ document.activeElement.blur(); return e.preventDefault(); } break; /* 以下、テキスト入力中は反応しない */ case(['input', 'textarea'].includes(document.activeElement.localName)): return; /* Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */ case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey): return; /* コメント入力欄フォーカス */ case(e.key === 'k'): case(e.key === ' '): case(e.key === 'Enter'): /* コメント欄が表示されていなければあらかじめ表示しておく */ if(site.isCommentPaneHidden()) site.elements.commentButton().click(); site.elements.commentTextarea().focus(); return e.preventDefault(); /* コメント */ case(e.key === 'c'): if(site.isCommentPaneHidden()) site.elements.commentButton().click(); else site.elements.closer().click(); return e.preventDefault(); /* 裏番組一覧 */ case(e.key === 'n'): site.elements.channelButton().click(); return e.preventDefault(); /* 番組表 */ case(e.key === 't'): site.elements.timetableButton().click(); return e.preventDefault(); /* 番組情報 */ case(e.key === 'i'): site.elements.programButton().click(); return e.preventDefault(); /* 10秒戻る(20秒かけて追いつく) */ case(e.key === 'j'): case(e.key === 'ArrowLeft'): const REWIND = 10, CATCHUP = 1.5; let videos = document.querySelectorAll('video[src]'); for(let i = 0, video; video = videos[i]; i++){ if(video.paused || video.rewinded) continue; if(video.currentTime > 1e9) continue;/*currentTimeがunixtimeならmpeg-dashで巻き戻し不可*/ let rewind = atMost(video.currentTime, REWIND); video.rewinded = true; video.currentTime = video.currentTime - rewind; video.playbackRate = CATCHUP; setTimeout(function(){ video.rewinded = false; video.playbackRate = 1; }, (rewind / (CATCHUP - 1))*1000); } return e.preventDefault(); /* フルスクリーン */ case(e.key === 'f'): site.elements.fullscreenButton().click(); return e.preventDefault(); /* ミュート */ case(e.key === 'm'): site.elements.muteButton().click(); if(site.getCurrentVolume() === 0) core.indicateVolume((elements.indicator.textContent === 'mute') ? 0 : 'mute'); else core.indicateVolume(site.getCurrentVolume()); return e.preventDefault(); /* ヘルプ */ case(e.key === 'h'): case(e.key === '/'): core.help.toggle('realtime'); return e.preventDefault(); } }, /* タイムシフト */ timeshift: function(e){ switch(true){ /* 音量 */ case(e.type === 'wheel' && Math.abs(e.deltaX) < Math.abs(e.deltaY)/*縦ホイールのみ*/): if(e.target === site.elements.stopper()){ site.modifyVolume(e); return e.preventDefault(); } return; /* 以下、テキスト入力中は反応しない */ case(['input', 'textarea'].includes(document.activeElement.localName)): return; /* Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */ case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey): return; /* 再生・停止トグル */ case(e.key === 'k'): case(e.key === ' '): case(e.key === 'Enter'): site.elements.playButton().click(); return e.preventDefault(); /* 10秒戻る */ case(e.key === 'j'): case(e.key === 'ArrowLeft'): site.elements.rewindButton().click(); return e.preventDefault(); /* 30秒進む */ case(e.key === 'l'): case(e.key === 'ArrowRight'): site.elements.advancesButton().click(); return e.preventDefault(); /* フルスクリーン */ case(e.key === 'f'): site.elements.fullscreenButton().click(); return e.preventDefault(); /* ミュート */ case(e.key === 'm'): site.elements.muteButton().click(); if(site.getCurrentVolume() === 0) core.indicateVolume((elements.indicator.textContent === 'mute') ? 0 : 'mute'); else core.indicateVolume(site.getCurrentVolume()); return e.preventDefault(); /* ヘルプ */ case(e.key === 'h'): case(e.key === '/'): core.help.toggle('timeshift'); return e.preventDefault(); } }, help: { open: function(type){ core.panel.open(elements.helpPanel || core.help.createPanel(type)); }, close: function(){ core.panel.close(elements.helpPanel); }, toggle: function(type){ core.panel.toggle(elements.helpPanel || core.help.createPanel(type), core.help.open.bind(null, type), core.help.close); }, createPanel: function(type){ let helpPanel = elements.helpPanel = createElement(core.html.helpPanel(type)); helpPanel.querySelector('button.ok').addEventListener('click', core.help.close); helpPanel.keyAssigns = { 'Escape': core.help.close, }; return helpPanel; }, }, /* パネル共通 */ 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]) 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) 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(){ let style = createElement(core.html.style()); document.head.appendChild(style); if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style); elements.style = style; }, html: { volumeIndicator: (type) => `
`, helpPanel: (type) => `