🏠 Home 

Video AB Repeater

動画のABリピート機能 ZenzaWatch用


Install this script?
// ==UserScript==
// @name        Video AB Repeater
// @namespace   https://github.com/segabito/
// @description 動画のABリピート機能 ZenzaWatch用
// @match       *://www.nicovideo.jp/*
// @match       *://ext.nicovideo.jp/
// @match       *://ext.nicovideo.jp/#*
// @match       *://ch.nicovideo.jp/*
// @match       *://com.nicovideo.jp/*
// @match       *://commons.nicovideo.jp/*
// @match       *://dic.nicovideo.jp/*
// @exclude     *://ads*.nicovideo.jp/*
// @exclude     *://www.upload.nicovideo.jp/*
// @exclude     *://www.nicovideo.jp/watch/*?edit=*
// @exclude     *://ch.nicovideo.jp/tool/*
// @exclude     *://flapi.nicovideo.jp/*
// @exclude     *://dic.nicovideo.jp/p/*
// @include     *://www.youtube.com/*
// @version     0.0.7
// @grant       none
// @author      segabito macmoto
// @license     public domain
// @noframes
// @require        https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.10.1/lodash.js
// @require        https://cdnjs.cloudflare.com/ajax/libs/fetch/2.0.1/fetch.js
// ==/UserScript==
(() => {
const PRODUCT = 'Repeater';
const monkey = function(PRODUCT) {
const console = window.console;
let ZenzaWatch = null;
console.log(`exec ${PRODUCT}..`);
const CONSTANT = {
BASE_Z_INDEX: 150000
};
const product = {debug: {_const: CONSTANT}};
window[PRODUCT] = product;
const {util, Emitter} = (function() {
const util = {};
class Emitter {
on(name, callback) {
if (!this._events) { this._events = {}; }
name = name.toLowerCase();
if (!this._events[name]) {
this._events[name] = [];
}
this._events[name].push(callback);
}
clear(name) {
if (!this._events) { this._events = {}; }
if (name) {
this._events[name] = [];
} else {
this._events = {};
}
}
emit(name) {
if (!this._events) { this._events = {}; }
name = name.toLowerCase();
if (!this._events.hasOwnProperty(name)) { return; }
const e = this._events[name];
const arg = Array.prototype.slice.call(arguments, 1);
for (let i =0, len = e.length; i < len; i++) {
e[i].apply(null, arg);
}
}
emitAsync(...args) {
window.setTimeout(() => {
this.emit(...args);
}, 0);
}
}
util.emitter = new Emitter();
return {util, Emitter};
})(PRODUCT);
product.util = util;
class BaseViewComponent extends Emitter {
constructor({parentNode = null, name = '', template = '', shadow = '', css = ''}) {
super();
this._params = {parentNode, name, template, shadow, css};
this._bound = {};
this._state = {};
this._props = {};
this._elm = {};
this._initDom({
parentNode,
name,
template,
shadow,
css
});
}
_initDom({parentNode, name, template, css = '', shadow = ''}) {
let tplId = `${PRODUCT}${name}Template`;
let tpl = document.getElementById(tplId);
if (!tpl) {
if (css) { util.addStyle(css, `${name}Style`); }
tpl = document.createElement('template');
tpl.innerHTML = template;
tpl.id = tplId;
document.body.appendChild(tpl);
}
const onClick = this._bound.onClick = this._onClick.bind(this);
const view = document.importNode(tpl.content, true);
this._view = view.querySelector('*') || document.createDocumentFragment();
if (this._view) {
this._view.addEventListener('click', onClick);
}
this.appendTo(parentNode);
if (shadow) {
this._attachShadow({host: this._view, name, shadow});
if (!this._isDummyShadow) {
this._shadow.addEventListener('click', onClick);
}
}
}
_attachShadow ({host, shadow, name, mode = 'open'}) {
let tplId = `${PRODUCT}${name}Shadow`;
let tpl = document.getElementById(tplId);
if (!tpl) {
tpl = document.createElement('template');
tpl.innerHTML = shadow;
tpl.id = tplId;
document.body.appendChild(tpl);
}
if (!host.attachShadow && !host.createShadowRoot) {
return this._fallbackNoneShadowDom({host, tpl, name});
}
const root = host.attachShadow ?
host.attachShadow({mode}) : host.createShadowRoot();
const node = document.importNode(tpl.content, true);
root.appendChild(node);
this._shadowRoot = root;
this._shadow = root.querySelector('.root');
this._isDummyShadow = false;
}
_fallbackNoneShadowDom({host, tpl, name}) {
const node = document.importNode(tpl.content, true);
const style = node.querySelector('style');
style.remove();
util.addStyle(style.innerHTML, `${name}Shadow`);
host.appendChild(node);
this._shadow = this._shadowRoot = host.querySelector('.root');
this._isDummyShadow = true;
}
setState(key, val) {
if (typeof key === 'string') {
this._setState(key, val);
}
Object.keys(key).forEach(k => {
this._setState(k, key[k]);
});
}
_setState(key, val) {
if (this._state[key] !== val) {
this._state[key] = val;
if (/^is(.*)$/.test(key))  {
this.toggleClass(`is-${RegExp.$1}`, !!val);
}
this.emit('update', {key, val});
}
}
_onClick(e) {
const target = e.target.classList.contains('command') ?
e.target : e.target.closest('.command');
if (!target) { return; }
const command = target.getAttribute('data-command');
if (!command) { return; }
const type  = target.getAttribute('data-type') || 'string';
let param   = target.getAttribute('data-param');
e.stopPropagation();
e.preventDefault();
param = this._parseParam(param, type);
this._onCommand(command, param);
}
_parseParam(param, type) {
switch (type) {
case 'json':
case 'bool':
case 'number':
param = JSON.parse(param);
break;
}
return param;
}
appendTo(parentNode) {
if (!parentNode) { return; }
this._parentNode = parentNode;
parentNode.appendChild(this._view);
}
_onCommand(command, param) {
this.emit('command', command, param);
}
toggleClass(className, v) {
(className || '').split(/ +/).forEach((c) => {
if (this._view && this._view.classList) {
this._view.classList.toggle(c, v);
}
if (this._shadow && this._shadow.classList) {
this._shadow.classList.toggle(c, this._view.classList.contains(c));
}
});
}
addClass(name)    { this.toggleClass(name, true); }
removeClass(name) { this.toggleClass(name, false); }
}
class RepeaterRange extends BaseViewComponent {
constructor({parentNode, repeater}) {
super({
parentNode,
name: 'VideoABRepeaterRange',
shadow: RepeaterRange.__shadow__,
template: '<div class="RepeaterRange"></div>',
css: ''
});
this._a = -1;
this._b = -1;
this._repeater = repeater;
repeater.on('range', () => {
this.setA(repeater.getA());
this.setB(repeater.getB());
this.refresh();
});
}
get _duration() {
return this._repeater.duration;
}
setA(v) {
this._a = v;
}
setB(v) {
this._b = v;
}
_reset() {
this._shadow.style.display = 'none';
this._a = -1;
this._b = -1;
}
onVideoChange() {
this._reset();
}
_timeToPer(time) {
return (time / Math.max(this._duration, 1)) * 100;
}
refresh() {
this._shadow.style.display = this._b < 0 ? 'none': '';
if (this._b < 0) {
return;
}
const perLeft = (this._timeToPer(this._a));
const scaleX = this._timeToPer(this._b - this._a) / 100;
this._shadow.style.transform =
`translate3d(${perLeft}%, 0, 0) scaleX(${scaleX})`;
this._shadow.setAttribute('data-pos', `a: ${this._a}, b: ${this._b}`);
}
}
RepeaterRange.__shadow__ = `
<style>
.root {
pointer-events: none;
position: absolute;
width: 100vw;
height: 100%;
left: 0px;
top: 0%;
/*box-shadow: 0 0 6px #333 inset, 0 0 4px #333;*/
z-index: 100;
background: rgba(255, 255, 90, 0.5);
transform-origin: left;
transform: translate3d(0, 0, 0) scaleX(0);
transition: transform 0.2s;
outline: 2px solid orange;
}
:host-context(.zenzaStoryboardOpen) .VideoAPRepeaterRange {
background: #ff9;
mix-blend-mode: lighten;
opacity: 0.5;
}
</style>
<div class="VideoABRepeaterRange root"></div>
`.trim();
class ContextMenuButton extends BaseViewComponent {
constructor({parentNode, repeater}) {
super({
parentNode,
name: 'ContextMenuButton',
shadow: ContextMenuButton.__shadow__,
template: '<div class="ContextMenuButton"></div>',
css: ''
});
}
_initDom(...args) {
super._initDom(...args);
const root = this._shadowRoot;
root.addEventListener('mousedown', e => {
e.preventDefault(); e.stopPropagation();
});
}
}
ContextMenuButton.__shadow__ = `
<style>
.root {
white-space: nowrap;
display: flex;
}
:host-context(.zenzaStoryboardOpen) .VideoAPRepeaterRange {
background: #ff9;
mix-blend-mode: lighten;
opacity: 0.5;
}
.controlButton {
width: 33%;
margin: 0;
padding: 0;
flex: 1;
height: 48px;
font-size: 24px;
line-height: 46px;
border: 1px solid;
border-radius: 4px;
color: #333;
background: rgba(192, 192, 192, 0.95);
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
box-shadow: 0 0 0;
opacity: 1;
margin: auto;
font-family: 'Arial Black';
}
.controlButton:hover {
transform: translate(0px, -4px);
box-shadow: 0px 4px 2px #666;
}
.controlButton:active {
transform: none;
box-shadow: 0 0 0;
border: 1px inset;
}
.controlButton .tooltip {
display: none;
pointer-events: none;
position: absolute;
left: 16px;
top: -30px;
transform:  translate(-50%, 0);
font-size: 12px;
line-height: 16px;
padding: 2px 4px;
border: 1px solid !000;
background: #ffc;
color: #000;
text-shadow: none;
white-space: nowrap;
z-index: 100;
opacity: 0.8;
}
:host-context(.is-mouseMoving) .controlButton:hover .tooltip {
display: block;
opacity: 1;
}
</style>
<div class="root">
<button class="controlButton command" data-command="setA">
A
<div class="tooltip">リピート開始点</div>
</button>
<button class="controlButton command" data-command="setB">
B
<div class="tooltip">リピート終了点</div>
</button>
<button class="controlButton command" data-command="reset">
*
<div class="tooltip">リピート解除</div>
</button>
</div>
`.trim();
const ZenzaDetector = (function() {
let isReady = false;
const emitter = new Emitter();
const onZenzaReady = () => {
isReady = true;
ZenzaWatch = window.ZenzaWatch;
emitter.emit('ready', window.ZenzaWatch);
};
if (window.ZenzaWatch && window.ZenzaWatch.ready) {
window.console.log('ZenzaWatch is Ready');
isReady = true;
} else {
document.body.addEventListener('ZenzaWatchInitialize', () => {
window.console.log('ZenzaWatchInitialize');
onZenzaReady();
});
}
const detect = function() {
return new Promise(res => {
if (isReady) {
return res(window.ZenzaWatch);
}
emitter.on('ready', () => {
res(window.ZenzaWatch);
});
});
};
return {detect};
})();
class Repeater extends Emitter {
constructor(params) {
super();
this._timer = null;
this._videoTime = params.videoTime;
this._notifier = params.notifier;
}
setA() {
this._a = this.currentTime;
if (this._b < this._a) {
this._b = this.duration + 1;
}
this.notify('set "A"');
this.emit('range');
this.enable();
}
getA() {
return this._a;
}
setB() {
this._b = this.currentTime;
if (this._b < this._a || this._a < 0) {
this._a = Math.max(this._b - 30, 0);
}
this.emit('range');
this.notify('set "B"');
this.enable();
}
getB() {
return this._b;
}
resetA() {
this._a = -1;
this.notify('reset "A"');
this.emit('range');
}
resetB() {
this._b = this.duration;
this.notify('reset "B"');
this.emit('range');
}
jumpToA() {
this.currentTime = Math.max(this._a, 0);
}
_reset() {
this._a = -1;
this._b = -1;
this.emit('range');
}
reset() {
this._reset();
}
get currentTime() {
return this._videoTime.get();
}
set currentTime(v) {
this._videoTime.set(v);
}
get duration() {
return this._videoTime.duration();
}
notify(msg) {
this._notifier.notify(msg);
}
enable() {
if (this._timer) { return; }
console.info('start timer', this._timer, this._rate);
this._timer = setInterval(this._onTimer.bind(this), 100);
}
disable() {
clearInterval(this._timer);
this._reset();
this._timer = null;
}
onVideoChange() {
this._reset();
}
_onTimer() {
if (this._b < 0) { return; }
if (this.currentTime > this._b) {
this.currentTime = Math.max(this._a, 0);
}
}
}
const KeyEmitter = (() => {
const emitter = new Emitter();
const onKeyDown = e => {
if (e.target.tagName === 'SELECT' ||
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA') {
return;
}
let keyCode = e.keyCode +
(e.metaKey  ? 0x1000000 : 0) +
(e.altKey   ? 0x100000  : 0) +
(e.ctrlKey  ? 0x10000   : 0) +
(e.shiftKey ? 0x1000    : 0);
emitter.emit('keydown', keyCode);
};
const onKeyUp = e => {
if (e.target.tagName === 'SELECT' ||
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA') {
return;
}
let keyCode = e.keyCode +
(e.metaKey  ? 0x1000000 : 0) +
(e.altKey   ? 0x100000  : 0) +
(e.ctrlKey  ? 0x10000   : 0) +
(e.shiftKey ? 0x1000    : 0);
switch (keyCode) {
}
emitter.emit('keyup', keyCode);
};
let initialize = () => {
initialize = () => {};
document.body.addEventListener('keydown', onKeyDown);
document.body.addEventListener('keyup', onKeyUp);
};
return {
on: (...args) => { emitter.on(...args); },
off: (...args) => { emitter.off(...args); },
initialize
};
})();
const initExternal = (repeater, range) => {
product.external = {
repeater
};
product.isReady = true;
const ev = new CustomEvent(`${PRODUCT}Initialized`, { detail: { product } });
document.body.dispatchEvent(ev);
};
const initKey = repeater => {
KeyEmitter.initialize();
KeyEmitter.on('keydown', code => {
switch (code) {
case 219: // [
repeater.setA();
//if (range) {
//  range.setA(repeater.getA());
//  range.setB(repeater.getB());
//}
break;
case 221: // ]
repeater.setB();
//if (range) {
//  range.setA(repeater.getA());
//  range.setB(repeater.getB());
//}
break;
case 219 + 0x1000:
repeater.jumpToA();
break;
case 221 + 0x1000:
repeater.reset();
//if (range) {
//  range.setA(repeater.getA());
//  range.setB(repeater.getB());
//}
break;
}
});
};
const initNico = () => {
let repeater, range, control;
ZenzaDetector.detect().then(() => {
const ZenzaWatch = window.ZenzaWatch;
const videoTime = {
get: () => {
return ZenzaWatch.external.getVideoElement().currentTime;
},
set: (v) => {
ZenzaWatch.external.getVideoElement().currentTime = v;
},
duration: () => {
return ZenzaWatch.external.getVideoElement().duration;
}
};
const notifier = {
notify: msg => {
//ZenzaWatch.external.execCommand('notify', msg);
}
};
let dialog;
ZenzaWatch.emitter.on('DialogPlayerOpen', () => {
if (!dialog) {
dialog = ZenzaWatch.debug.dialog;
dialog.on('loadVideoInfo', () => {
repeater.onVideoChange();
});
}
});
ZenzaWatch.emitter.on('DialogPlayerClose', () => {
repeater.disable();
});
repeater = new Repeater({videoTime, notifier});
ZenzaWatch.emitter.on('seekBar.addonMenuReady', container => {
range = new RepeaterRange({parentNode: container, repeater});
initKey(repeater, range);
initExternal(repeater);
});
ZenzaWatch.emitter.on('videoContextMenu.addonMenuReady', (container, handler) => {
control = new ContextMenuButton({parentNode: container});
control.on('command', (command, param) => {
switch(command) {
case 'setA':
repeater.setA();
break;
case 'setB':
repeater.setB();
break;
case 'reset':
repeater.reset();
document.body.click();
break;
}
});
});
});
};
const initTube = () => {
let video = document.querySelector('video.html5-main-video');
const getVideoElement = () => {
if (!video) {
video = document.querySelector('video.html5-main-video');
}
return video;
};
const videoTime = {
get: () => {
return getVideoElement().currentTime;
},
set: (v) => {
getVideoElement().currentTime = v;
},
duration: () => {
return getVideoElement().duration;
}
};
const notifier = {
notify: msg => {
console.log('%c%s', 'background: lightgreen;', msg);
}
};
let lastPath = location.pathname + location.search;
let repeater = new Repeater({videoTime, notifier});
initKey(repeater);
const onUpdatePage = () => {
if (lastPath !== location.pathname + location.search) {
lastPath = location.pathname + location.search;
repeater.onVideoChange();
}
};
window.setInterval(onUpdatePage, 1000);
};
if (location.host.match(/^[a-z0-9_-]+\.nicovideo\.jp$/)) {
initNico();
} else if (location.host.match(/youtube\.com$/)) {
initTube();
}
};
(() => {
const script = document.createElement('script');
script.id = `${PRODUCT}Loader`;
script.setAttribute('type', 'text/javascript');
script.setAttribute('charset', 'UTF-8');
script.appendChild(document.createTextNode(
`(${monkey})("${PRODUCT}");`
));
document.body.appendChild(script);
})();
})();