Created
March 15, 2021 13:49
-
-
Save zuzu/b48dff3f7ec27074090bc8eba0b310fd to your computer and use it in GitHub Desktop.
ニコニコ大百科で配布されたZenzaWatchDEV版_kaiにplayback周りの修正を入れたもの
This file has been truncated, but you can view the full file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name ZenzaWatch DEV版 | |
// @namespace https://github.com/segabito/ | |
// @description ZenzaWatchの開発 先行バージョン | |
// @match *://www.nicovideo.jp/* | |
// @match *://ext.nicovideo.jp/ | |
// @match *://ext.nicovideo.jp/#* | |
// @match *://blog.nicovideo.jp/* | |
// @match *://ch.nicovideo.jp/* | |
// @match *://com.nicovideo.jp/* | |
// @match *://commons.nicovideo.jp/* | |
// @match *://dic.nicovideo.jp/* | |
// @match *://ex.nicovideo.jp/* | |
// @match *://info.nicovideo.jp/* | |
// @match *://search.nicovideo.jp/* | |
// @match *://uad.nicovideo.jp/* | |
// @match *://api.search.nicovideo.jp/* | |
// @match *://*.nicovideo.jp/smile* | |
// @match *://site.nicovideo.jp/* | |
// @match *://anime.nicovideo.jp/* | |
// @match https://www.upload.nicovideo.jp/garage/* | |
// @match https://www.google.co.jp/search* | |
// @match https://www.google.com/search* | |
// @match https://*.bing.com/search* | |
// @match https://feedly.com/* | |
// @exclude *://ads.nicovideo.jp/* | |
// @exclude *://www.nicovideo.jp/watch/*?edit=* | |
// @exclude *://ch.nicovideo.jp/tool/* | |
// @exclude *://flapi.nicovideo.jp/* | |
// @exclude *://dic.nicovideo.jp/p/* | |
// @exclude *://ext.nicovideo.jp/thumb/* | |
// @exclude *://ext.nicovideo.jp/thumb_channel/* | |
// @grant none | |
// @author segabito | |
// @version 2.6.2.x | |
// @run-at document-body | |
// @require https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js | |
// ==/UserScript== | |
/* eslint-disable */ | |
// import {SettingPanel} from './SettingPanel'; | |
const AntiPrototypeJs = function() { | |
if (this.promise !== null || !window.Prototype || window.PureArray) { | |
return this.promise || Promise.resolve(window.PureArray || window.Array); | |
} | |
if (document.getElementsByClassName.toString().indexOf('B,A') >= 0) { | |
delete document.getElementsByClassName; | |
} | |
const waitForDom = new Promise(resolve => { | |
if (['interactive', 'complete'].includes(document.readyState)) { | |
return resolve(); | |
} | |
document.addEventListener('DOMContentLoaded', resolve, {once: true}); | |
}); | |
const f = Object.assign(document.createElement('iframe'), { | |
srcdoc: '<html><title>ここだけ時間が10年遅れてるスレ</title></html>', | |
id: 'prototype', | |
loading: 'eager' | |
}); | |
Object.assign(f.style, {position: 'absolute', left: '-100vw', top: '-100vh'}); | |
return this.promise = waitForDom | |
.then(() => new Promise(res => { | |
f.onload = res; | |
document.body.append(f); | |
})).then(() => { | |
window.PureArray = f.contentWindow.Array; | |
delete window.Array.prototype.toJSON; | |
delete window.String.prototype.toJSON; | |
f.remove(); | |
return Promise.resolve(window.PureArray); | |
}).catch(err => console.error(err)); | |
}.bind({promise: null}); | |
AntiPrototypeJs(); | |
(() => { | |
try { | |
if (window.top === window) { | |
window.ZenzaLib = { _ }; | |
console.log('@require', JSON.stringify({lodash: _.VERSION})); | |
} | |
} catch(e) { | |
window.top === window && console.warn('@require failed!', location, e); | |
} | |
})(); | |
(function (window) { | |
const self = window; | |
const document = window.document; | |
'use strict'; | |
const PRODUCT = 'ZenzaWatch'; | |
// 公式プレイヤーがurlを書き換えてしまうので読み込んでおく | |
const START_PAGE_QUERY = (location.search ? location.search.substring(1) : ''); | |
const monkey = async (PRODUCT, START_PAGE_QUERY) /*** (`・ω・´)9m ***/ => { | |
const Array = window.PureArray ? window.PureArray : window.Array; | |
let console = window.console; | |
let $ = window.ZenzaJQuery || window.jQuery, _ = window.ZenzaLib ? window.ZenzaLib._ : window._; | |
let TOKEN = 'r:' + (Math.random()); | |
let CONFIG = null; | |
const NICORU = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAGh0lEQVRIS3VWeWxUxxn/zbxjvWuvvV4vXp9g43OddTB2EYEWnEuKgl3USqRHIpRIbVS7KapUmSbUIUpBBIjdtGpKHan9p0IkkQhNGtlUFaWFkGIi4WJq8BqMbQ4fuz7WV3bt3fdmXjWza8uozZOe3ryZb77fd/zm+4bgfx8VgCmmczN21HocoefiLFYfnI3VzBtBu5jP0HKWcjJtvbpiuzgd9Z6emL/076Sa1b0raska/WJMATBgp6/MM9o+MjO1y7QWV0W2Fmly/MVdY3VOJU4UZ607Ozhd0AJ8FgCgAOAALCG0AiC+4uUObXOT13mvYyQcFuv8t3sL2PbKdJrr0qnTpkj5xRizJubivHtgge87OSoU0mK3G6HFDc1R49p7SUMFgLUCIIRYul59yKENHQxGomj/fr6xd0e2lu3RAUIBzgEujUqYQhNbJ6fjOHlp0mj5YEzLSXUgapQcXoj3vZH0hAkpGTcbrWvKtA90BCMRs6ullO7akkW5YWEuwqSzKTpBio0mHQfiJgfnFuw2CqJSnL06wxva7vCc1FR1dqmyOcZ7hCdq0oOnfcXu6/0j4Sl0tpTyhq3rqBU3cerSFE6cC8KhEzzzqAs/3ZUPm41iaGwJv+oag6YAlBLs/2Yh8nId6Oqe5I3td2ixex1GwpuqgL8HJECZp7xzcPp2Q9v38o2WbxVq3OQyQ8c+foDXz0zIUHxnSzr++KMyONNVdPfPY/ubA6uJvnm8GlXr7TJ07Z+MGfs/HNPKPOVdg9O3G0luxpO104vXegw+y4MnNlNvlgZmchBQvNM5iv0fjktFP9jpwm9eKkFaqoqrtxaw5Y0AqrwU/SGOW21+lBc4pFwobCDnlWtco5nU49xcR/y5/rduTNw48O7eAuMnjfkaMxgoIbAsgl93jqIlCfByvQvvvPgwQE2+gt4xhoG2alQU2mEaFlSd4nedY8a+k6OaP9d/lFRkl1y+NTm07eqRKlZX5lRYjIOKXFoEh8/cx5sfB6VljZuceH9fuQzRlf55bFsTov63q+FbnwSwUfQMLrKvtfYrFdkl3cSl50fn4mP28RM1Vm6WTpgJECJYaOHcf+Zxvm8WCgX8hWnYs9UDTSeYmInj054wrCS7dte54XbqYJxBUalYt/Je6RW6l0SSra+X6PjrgWo4UxVwJgASfCeEgHHhDaAKMnMLMjvCAvGKheSXi7EFUAVYjDA8e7QP/xqKyyNjPVVpw6c/98ORokpuCwCx73zfPL4YXJTeVBWmoqE2CwolmF00cerzEJbiDAYDvrvNg5I8OxiDXI8um9j99g2cH4iBKMQTYda0I/RejZXt0gmXIbJkDg59dA+//CQkvXnpGxno+GEZUlIohsdjKPnZ9VWanjtQjqc3uWEaDKpGMDkXt7xNvUJ3lJS6vZfvhEPbAm3VrHK9Q3mIRV2jaPkgQdOWZz04+nwxVBvFg4llbGntQ1Ya0B/kuPB6Ber9GassGrgfZb79fUqp29tNavK9b/WOhQ6c+nGR8fzjXs2McZlU4cHac9D8pAut3y6CQ1cwMrWMHYcCyEkDhsMc/2ytwOPVSQAbxfsXQsYLv7+r1eR7jxKfZ0NtYPp+z/YSjf+ttZqmrcnDkT/fx8EziRCJx5+nSQovxS0MTsqWIZ9//KICTzyaATALX8Y4njnSxy8PGdTnWV8nS4XPm9oZCEUaTu/baOzZ6dWMZROaQvH5wByO/WUcMcPEcpzDYFx6JkB0lUBXKSrzHHhtdyHysjQQjeKjS1PGc+8Oaz5valcgFGmUAFl6ViVR5gLTSwz9xx/hvo3p1Fw2ZagiMY54XNQmskpfsUcCEQJ7CpHGKDYFgeEFXvXqTeqxK7CYyzcTnxlYLddFmY6mu7PRDkUhZuD4I7Rsg1NW1ITF4lxQIHk+Em1EeJM4BtBUDN5b5L5Xb3LGLLUo09F8dza6tlzLNseK3eqhkbB5UFh4/rVyo97v0hSdyNhaPEHdxAG0QETDUQhY3MLFG3PGU8duy35a7FYPj4TNhxqO3LPSMjdmak3jC0bHMgNe3uniL9bnsMoCB013UKqpiTZmmNxaiHI+MBrlf7oYVP7w2RxNUYC8dK15eNb4vy1zBUQ2/dw03edKZe2BENuV4AnBC485UZpjk393gjGcuiIuA4mS4vMqZ+ciSsvEl/GvbPqrlFtpoWLisQ1abYxbe649MJ8AsAmAvLYAWAJwfXOBesGmkNNX7hlfeW35LyB037N9NspNAAAAAElFTkSuQmCC'; | |
const dll = {}; | |
const util = {}; | |
let {dimport, workerUtil, IndexedDbStorage, Handler, PromiseHandler, Emitter, parseThumbInfo, WatchInfoCacheDb, StoryboardCacheDb, VideoSessionWorker} = window.ZenzaLib; | |
START_PAGE_QUERY = encodeURIComponent(START_PAGE_QUERY); | |
var VER = '2.6.2'; | |
const ENV = 'DEV'; | |
console.log( | |
`%c${PRODUCT}@${ENV} v${VER}%c (゚∀゚) ゼンザ! %cNicorü? %c田%c \n\nplatform: ${navigator.platform}\nua: ${navigator.userAgent}`, | |
'font-family: Chalkduster; font-size: 200%; background: #039393; color: #ffc; padding: 8px; text-shadow: 2px 2px #888;', | |
'', | |
'font-family: "Chalkboard SE", Chalkduster,HeadLineA; font-size: 24px;', | |
'display: inline-block; font-size: 24px; color: transparent; background-repeat: no-repeat; background-position: center; background-size: contain;' + | |
`background-image: url(${NICORU});`, | |
'line-height: 1.25; font-weight: bold; ' | |
); | |
console.nicoru = | |
console.log.bind(console, | |
'%c ', | |
'display: inline-block; font-size: 120%; color: transparent; background-repeat: no-repeat; background-position: center; background-size: contain;' + | |
`background-image: url(${NICORU})` | |
); | |
const StorageWriter = (() => { | |
const func = function(self) { | |
self.onmessage = ({command, params}) => { | |
const {obj, replacer, space} = params; | |
return JSON.stringify(obj, replacer || null, space || 0); | |
}; | |
}; | |
let worker; | |
const prototypePollution = window.Prototype && Array.prototype.hasOwnProperty('toJSON'); | |
const toJson = async (obj, replacer = null, space = 0) => { | |
if (!prototypePollution || obj === null || ['string', 'number', 'boolean'].includes(typeof obj)) { | |
return JSON.stringify(obj, replacer, space); | |
} | |
worker = worker || workerUtil.createCrossMessageWorker(func, {name: 'ToJsonWorker'}); | |
return worker.post({command: 'toJson', params: {obj, replacer, space}}); | |
}; | |
const writer = Symbol('StorageWriter'); | |
const setItem = (storage, key, value) => { | |
if (!prototypePollution || value === null || ['string', 'number', 'boolean'].includes(typeof value)) { | |
storage.setItem(key, JSON.stringify(value)); | |
} else { | |
toJson(value).then(json => storage.setItem(key, json)); | |
} | |
}; | |
localStorage[writer] = (key, value) => setItem(localStorage, key, value); | |
sessionStorage[writer] = (key, value) => setItem(sessionStorage, key, value); | |
return { writer, toJson }; | |
})(); | |
const objUtil = (() => { | |
const isObject = e => e !== null && e instanceof Object; | |
const PROPS = Symbol('PROPS'); | |
const REVISION = Symbol('REVISION'); | |
const CHANGED = Symbol('CHANGED'); | |
const HAS = Symbol('HAS'); | |
const SET = Symbol('SET'); | |
const GET = Symbol('GET'); | |
return { | |
bridge: (self, target, keys = null) => { | |
(keys || Object.getOwnPropertyNames(target.constructor.prototype)) | |
.filter(key => typeof target[key] === 'function') | |
.forEach(key => self[key] = target[key].bind(target)); | |
}, | |
isObject, | |
toMap: (obj, mapper = Map) => { | |
if (obj instanceof mapper) { | |
return obj; | |
} | |
return new mapper(Object.entries(obj)); | |
}, | |
mapToObj: map => { | |
if (!(map instanceof Map)) { | |
return map; | |
} | |
const obj = {}; | |
for (const [key, val] of map) { | |
obj[key] = val; | |
} | |
return obj; | |
}, | |
}; | |
})(); | |
const Observable = (() => { | |
const observableSymbol = Symbol.observable || Symbol('observable'); | |
const nop = Handler.nop; | |
class Subscription { | |
constructor({observable, subscriber, unsubscribe, closed}) { | |
this.callbacks = {unsubscribe, closed}; | |
this.observable = observable; | |
const next = subscriber.next.bind(subscriber); | |
subscriber.next = args => { | |
if (this.closed || (this._filterFunc && !this._filterFunc(args))) { | |
return; | |
} | |
return this._mapFunc ? next(this._mapFunc(args)) : next(args); | |
}; | |
this._closed = false; | |
} | |
subscribe(subscriber, onError, onCompleted) { | |
return this.observable.subscribe(subscriber, onError, onCompleted) | |
.filter(this._filterFunc) | |
.map(this._mapFunc); | |
} | |
unsubscribe() { | |
this._closed = true; | |
if (this.callbacks.unsubscribe) { | |
this.callbacks.unsubscribe(); | |
} | |
return this; | |
} | |
dispose() { | |
return this.unsubscribe(); | |
} | |
filter(func) { | |
const _func = this._filterFunc; | |
this._filterFunc = _func ? (arg => _func(arg) && func(arg)) : func; | |
return this; | |
} | |
map(func) { | |
const _func = this._mapFunc; | |
this._mapFunc = _func ? arg => func(_func(arg)) : func; | |
return this; | |
} | |
get closed() { | |
if (this.callbacks.closed) { | |
return this._closed || this.callbacks.closed(); | |
} else { | |
return this._closed; | |
} | |
} | |
} | |
class Subscriber { | |
static create(onNext = null, onError = null, onCompleted = null) { | |
if (typeof onNext === 'function') { | |
return new this({ | |
next: onNext, | |
error: onError, | |
complete: onCompleted | |
}); | |
} | |
return new this(onNext || {}); | |
} | |
constructor({start, next, error, complete} = {start:nop, next:nop, error:nop, complete:nop}) { | |
this.callbacks = {start, next, error, complete}; | |
} | |
start(arg) {this.callbacks.start(arg);} | |
next(arg) {this.callbacks.next(arg);} | |
error(arg) {this.callbacks.error(arg);} | |
complete(arg) {this.callbacks.complete(arg);} | |
get closed() { | |
return this._callbacks.closed ? this._callbacks.closed() : false; | |
} | |
} | |
Subscriber.nop = {start: nop, next: nop, error: nop, complete: nop, closed: nop}; | |
const eleMap = new WeakMap(); | |
class Observable { | |
static of(...args) { | |
return new this(o => { | |
for (const arg of args) { | |
o.next(arg); | |
} | |
o.complete(); | |
return () => {}; | |
}); | |
} | |
static from(arg) { | |
if (arg[Symbol.iterator]) { | |
return this.of(...arg); | |
} else if (arg[Observable.observavle]) { | |
return arg[Observable.observavle](); | |
} | |
} | |
static fromEvent(element, eventName) { | |
const em = eleMap.get(element) || {}; | |
if (em && em[eventName]) { | |
return em[eventName]; | |
} | |
eleMap.set(element, em); | |
return em[eventName] = new this(o => { | |
const onUpdate = e => o.next(e); | |
element.addEventListener(eventName, onUpdate, {passive: true}); | |
return () => element.removeEventListener(eventName, onUpdate); | |
}); | |
} | |
static interval(ms) { | |
return new this(function(o) { | |
const timer = setInterval(() => o.next(this.i++), ms); | |
return () => clearInterval(timer); | |
}.bind({i: 0})); | |
} | |
constructor(subscriberFunction) { | |
this._subscriberFunction = subscriberFunction; | |
this._completed = false; | |
this._cancelled = false; | |
this._handlers = new Handler(); | |
} | |
_initSubscriber() { | |
if (this._subscriber) { | |
return; | |
} | |
const handlers = this._handlers; | |
this._completed = this._cancelled = false; | |
return this._subscriber = new Subscriber({ | |
start: arg => handlers.execMethod('start', arg), | |
next: arg => handlers.execMethod('next', arg), | |
error: arg => handlers.execMethod('error', arg), | |
complete: arg => { | |
if (this._nextObservable) { | |
this._nextObservable.subscribe(this._subscriber); | |
this._nextObservable = this._nextObservable._nextObservable; | |
} else { | |
this._completed = true; | |
handlers.execMethod('complete', arg); | |
} | |
}, | |
closed: () => this.closed | |
}); | |
} | |
get closed() { | |
return this._completed || this._cancelled; | |
} | |
filter(func) { | |
return this.subscribe().filter(func); | |
} | |
map(func) { | |
return this.subscribe().map(func); | |
} | |
concat(arg) { | |
const observable = Observable.from(arg); | |
if (this._nextObservable) { | |
this._nextObservable.concat(observable); | |
} else { | |
this._nextObservable = observable; | |
} | |
return this; | |
} | |
forEach(callback) { | |
let p = new PromiseHandler(); | |
callback(p); | |
return this.subscribe({ | |
next: arg => { | |
const lp = p; | |
p = new PromiseHandler(); | |
lp.resolve(arg); | |
callback(p); | |
}, | |
error: arg => { | |
const lp = p; | |
p = new PromiseHandler(); | |
lp.reject(arg); | |
callback(p); | |
}}); | |
} | |
onStart(arg) { this._subscriber.start(arg); } | |
onNext(arg) { this._subscriber.next(arg); } | |
onError(arg) { this._subscriber.error(arg); } | |
onComplete(arg) { this._subscriber.complete(arg);} | |
disconnect() { | |
if (!this._disconnectFunction) { | |
return; | |
} | |
this._closed = true; | |
this._disconnectFunction(); | |
delete this._disconnectFunction; | |
this._subscriber; | |
this._handlers.clear(); | |
} | |
[observableSymbol]() { | |
return this; | |
} | |
subscribe(onNext = null, onError = null, onCompleted = null) { | |
this._initSubscriber(); | |
const isNop = [onNext, onError, onCompleted].every(f => f === null); | |
const subscriber = Subscriber.create(onNext, onError, onCompleted); | |
return this._subscribe({subscriber, isNop}); | |
} | |
_subscribe({subscriber, isNop}) { | |
if (!isNop && !this._disconnectFunction) { | |
this._disconnectFunction = this._subscriberFunction(this._subscriber); | |
} | |
!isNop && this._handlers.add(subscriber); | |
return new Subscription({ | |
observable: this, | |
subscriber, | |
unsubscribe: () => { | |
if (isNop) { return; } | |
this._handlers.remove(subscriber); | |
if (this._handlers.isEmpty) { | |
this.disconnect(); | |
} | |
}, | |
closed: () => this.closed | |
}); | |
} | |
} | |
Observable.observavle = observableSymbol; | |
return Observable; | |
})(); | |
const WindowResizeObserver = Observable.fromEvent(window, 'resize') | |
.map(o => { return {width: window.innerWidth, height: window.innerHeight}; }); | |
const bounce = { | |
origin: Symbol('origin'), | |
idle(func, time) { | |
let reqId = null; | |
let lastArgs = null; | |
let promise = new PromiseHandler(); | |
const [caller, canceller] = | |
(time === undefined && self.requestIdleCallback) ? | |
[self.requestIdleCallback, self.cancelIdleCallback] : [self.setTimeout, self.clearTimeout]; | |
const callback = () => { | |
const lastResult = func(...lastArgs); | |
promise.resolve({lastResult, lastArgs}); | |
reqId = lastArgs = null; | |
promise = new PromiseHandler(); | |
}; | |
const result = (...args) => { | |
if (reqId) { | |
reqId = canceller(reqId); | |
} | |
lastArgs = args; | |
reqId = caller(callback, time); | |
return promise; | |
}; | |
result[this.origin] = func; | |
return result; | |
}, | |
time(func, time = 0) { | |
return this.idle(func, time); | |
} | |
}; | |
const throttle = (func, interval) => { | |
let lastTime = 0; | |
let timer; | |
let promise = new PromiseHandler(); | |
const result = (...args) => { | |
if (timer) { | |
return promise; | |
} | |
const now = performance.now(); | |
const timeDiff = now - lastTime; | |
timer = setTimeout(() => { | |
lastTime = performance.now(); | |
timer = null; | |
const lastResult = func(...args); | |
promise.resolve({lastResult, lastArgs: args}); | |
promise = new PromiseHandler(); | |
}, Math.max(interval - timeDiff, 0)); | |
return promise; | |
}; | |
result.cancel = () => { | |
if (timer) { | |
timer = clearTimeout(timer); | |
} | |
promise.resolve({lastResult: null, lastArgs: null}); | |
promise = new PromiseHandler(); | |
}; | |
return result; | |
}; | |
throttle.time = (func, interval = 0) => throttle(func, interval); | |
throttle.raf = function(func) { | |
let promise; | |
let cancelled = false; | |
let lastArgs = []; | |
const callRaf = res => requestAnimationFrame(res); | |
const onRaf = () => this.req = null; | |
const onCall = () => { | |
if (cancelled) { | |
cancelled = false; | |
return; | |
} | |
try { func(...lastArgs); } catch (e) { console.warn(e); } | |
promise = null; | |
}; | |
const result = (...args) => { | |
lastArgs = args; | |
if (promise) { | |
return promise; | |
} | |
if (!this.req) { | |
this.req = new Promise(callRaf).then(onRaf); | |
} | |
promise = this.req.then(onCall); | |
return promise; | |
}; | |
result.cancel = () => { | |
cancelled = true; | |
promise = null; | |
}; | |
return result; | |
}.bind({req: null, count: 0, id: 0}); | |
throttle.idle = func => { | |
let id; | |
const request = (self.requestIdleCallback || self.setTimeout); | |
const cancel = (self.cancelIdleCallback || self.clearTimeout); | |
const result = (...args) => { | |
if (id) { | |
return; | |
} | |
id = request(() => { | |
id = null; | |
func(...args); | |
}, 0); | |
}; | |
result.cancel = () => { | |
if (id) { | |
id = cancel(id); | |
} | |
}; | |
return result; | |
}; | |
class DataStorage { | |
static create(defaultData, options = {}) { | |
return new DataStorage(defaultData, options); | |
} | |
static clone(dataStorage) { | |
const options = { | |
prefix: dataStorage.prefix, | |
storage: dataStorage.storage, | |
ignoreExportKeys: dataStorage.options.ignoreExportKeys, | |
readonly: dataStorage.readonly | |
}; | |
return DataStorage.create(dataStorage.default, options); | |
} | |
constructor(defaultData, options = {}) { | |
this.options = options; | |
this.default = defaultData; | |
this._data = Object.assign({}, defaultData); | |
this.prefix = `${options.prefix || 'DATA'}_`; | |
this.storage = options.storage || localStorage; | |
this._ignoreExportKeys = options.ignoreExportKeys || []; | |
this.readonly = options.readonly; | |
this.silently = false; | |
this._changed = new Map(); | |
this._onChange = bounce.time(this._onChange.bind(this)); | |
objUtil.bridge(this, new Emitter()); | |
this.restore().then(() => { | |
this.props = this._makeProps(defaultData); | |
this.emitResolve('restore'); | |
}); | |
this.logger = (self || window).console; | |
this.consoleSubscriber = { | |
next: (v, ...args) => this.logger.log('next', v, ...args), | |
error: (e, ...args) => this.logger.warn('error', e, ...args), | |
complete: (c, ...args) => this.logger.log('complete', c, ...args) | |
}; | |
} | |
_makeProps(defaultData = {}, namespace = '') { | |
namespace = namespace ? `${namespace}.` : ''; | |
const self = this; | |
const def = {}; | |
const props = {}; | |
Object.keys(defaultData).sort() | |
.filter(key => key.includes(namespace)) | |
.forEach(key => { | |
const k = key.slice(namespace.length); | |
if (k.includes('.')) { | |
const ns = k.slice(0, k.indexOf('.')); | |
props[ns] = this._makeProps(defaultData, `${namespace}${ns}`); | |
} | |
def[k] = { | |
enumerable: !this._ignoreExportKeys.includes(key), | |
get() { return self.getValue(key); }, | |
set(v) { self.setValue(key, v); } | |
}; | |
}); | |
Object.defineProperties(props, def); | |
return props; | |
} | |
_onChange() { | |
const changed = this._changed; | |
this.emit('change', changed); | |
for (const [key, val] of changed) { | |
this.emitAsync('update', key, val); | |
this.emitAsync(`update-${key}`, val); | |
} | |
this._changed.clear(); | |
} | |
onkey(key, callback) { | |
this.on(`update-${key}`, callback); | |
} | |
offkey(key, callback) { | |
this.off(`update-${key}`, callback); | |
} | |
async restore(storage) { | |
storage = storage || this.storage; | |
Object.keys(this.default).forEach(key => { | |
const storageKey = this.getStorageKey(key); | |
if (storage.hasOwnProperty(storageKey) || storage[storageKey] !== undefined) { | |
try { | |
this._data[key] = JSON.parse(storage[storageKey]); | |
} catch (e) { | |
console.error('config parse error key:"%s" value:"%s" ', key, storage[storageKey], e); | |
delete storage[storageKey]; | |
this._data[key] = this.default[key]; | |
} | |
} else { | |
this._data[key] = this.default[key]; | |
} | |
}); | |
} | |
getNativeKey(key) { | |
return key; | |
} | |
getStorageKey(key) { | |
return `${this.prefix}${key}`; | |
} | |
async refresh(key, storage) { | |
storage = storage || this.storage; | |
key = this.getNativeKey(key); | |
const storageKey = this.getStorageKey(key); | |
if (storage.hasOwnProperty(storageKey) || storage[storageKey] !== undefined) { | |
try { | |
this._data[key] = JSON.parse(storage[storageKey]); | |
} catch (e) { | |
console.error('config parse error key:"%s" value:"%s" ', key, storage[storageKey], e); | |
} | |
} | |
return this._data[key]; | |
} | |
getValue(key) { | |
key = this.getNativeKey(key); | |
return this._data[key]; | |
} | |
deleteValue(key) { | |
key = this.getNativeKey(key); | |
const storageKey = this.getStorageKey(key); | |
this.storage.removeItem(storageKey); | |
this._data[key] = this.default[key]; | |
} | |
setValue(key, value) { | |
const _key = key; | |
key = this.getNativeKey(key); | |
if (this._data[key] === value || value === undefined) { | |
return; | |
} | |
const storageKey = this.getStorageKey(key); | |
const storage = this.storage; | |
if (!this.readonly) { | |
try { | |
storage[storageKey] = JSON.stringify(value); | |
} catch (e) { | |
window.console.error(e); | |
} | |
} | |
this._data[key] = value; | |
if (!this.silently) { | |
this._changed.set(_key, value); | |
this._onChange(); | |
} | |
} | |
setValueSilently(key, value) { | |
const isSilent = this.silently; | |
this.silently = true; | |
this.setValue(key, value); | |
this.silently = isSilent; | |
} | |
export(isAll = false) { | |
const result = {}; | |
const _default = this.default; | |
Object.keys(this.props) | |
.filter(key => isAll || (_default[key] !== this._data[key])) | |
.forEach(key => result[key] = this.getValue(key)); | |
return result; | |
} | |
exportJson() { | |
return JSON.stringify(this.export(), null, 2); | |
} | |
import(data) { | |
Object.keys(this.props) | |
.forEach(key => { | |
const val = data.hasOwnProperty(key) ? data[key] : this.default[key]; | |
console.log('import data: %s=%s', key, val); | |
this.setValueSilently(key, val); | |
}); | |
} | |
importJson(json) { | |
this.import(JSON.parse(json)); | |
} | |
getKeys() { | |
return Object.keys(this.props); | |
} | |
clearConfig() { | |
this.silently = true; | |
const storage = this.storage; | |
Object.keys(this.default) | |
.filter(key => !this._ignoreExportKeys.includes(key)).forEach(key => { | |
const storageKey = this.getStorageKey(key); | |
try { | |
if (storage.hasOwnProperty(storageKey) || storage[storageKey] !== undefined) { | |
console.nicoru('delete storage', storageKey, storage[storageKey]); | |
delete storage[storageKey]; | |
} | |
this._data[key] = this.default[key]; | |
} catch (e) {} | |
}); | |
this.silently = false; | |
} | |
namespace(name) { | |
const namespace = name ? `${name}.` : ''; | |
const origin = Symbol(`${namespace}`); | |
const result = { | |
getValue: key => this.getValue(`${namespace}${key}`), | |
setValue: (key, value) => this.setValue(`${namespace}${key}`, value), | |
on: (key, func) => { | |
if (key === 'update') { | |
const onUpdate = (key, value) => { | |
if (key.startsWith(namespace)) { | |
func(key.slice(namespace.length + 1), value); | |
} | |
}; | |
onUpdate[origin] = func; | |
this.on('update', onUpdate); | |
return result; | |
} | |
return this.onkey(`${namespace}${key}`, func); | |
}, | |
off: (key, func) => { | |
if (key === 'update') { | |
func = func[origin] || func; | |
this.off('update', func); | |
return result; | |
} | |
return this.offkey(`${namespace}${key}`, func); | |
}, | |
onkey: (key, func) => { | |
this.on(`update-${namespace}${key}`, func); | |
return result; | |
}, | |
offkey: (key, func) => { | |
this.off(`update-${namespace}${key}`, func); | |
return result; | |
}, | |
props: this.props[name], | |
refresh: () => this.refresh(), | |
subscribe: subscriber => { | |
return this.subscribe(subscriber) | |
.filter(changed => changed.keys().some(k => k.startsWith(namespace))) | |
.map(changed => { | |
const result = new Map; | |
for (const k of changed.keys()) { | |
k.startsWith(namespace) && result.set(k, changed.get(k)); | |
} | |
return result; | |
}); | |
} | |
}; | |
return result; | |
} | |
subscribe(subscriber) { | |
subscriber = subscriber || this.consoleSubscriber; | |
const observable = new Observable(o => { | |
const onChange = changed => o.next(changed); | |
this.on('change', onChange); | |
return () => this.off('change', onChange); | |
}); | |
return observable.subscribe(subscriber); | |
} | |
watch() { | |
} | |
unwatch() { | |
this.consoleSubscription && this.consoleSubscription.unsubscribe(); | |
this.consoleSubscription = null; | |
} | |
} | |
const Config = (() => { | |
const DEFAULT_CONFIG = { | |
debug: false, | |
volume: 0.3, | |
forceEnable: false, | |
showComment: true, | |
autoPlay: true, | |
'autoPlay:ginza': true, | |
'autoPlay:others': true, | |
loop: false, | |
mute: false, | |
screenMode: 'normal', | |
'screenMode:ginza': 'normal', | |
'screenMode:others': 'normal', | |
autoFullScreen: false, | |
autoCloseFullScreen: true, // 再生終了時に自動でフルスクリーン解除するかどうか | |
continueNextPage: false, // 動画再生中にリロードやページ切り替えしたら続きから開き直す | |
backComment: false, // コメントの裏流し | |
autoPauseCommentInput: true, // コメント入力時に自動停止する | |
sharedNgLevel: 'MID', // NG共有の強度 NONE, LOW, MID, HIGH, MAX | |
enablePushState: true, // ブラウザの履歴に乗せる | |
enableHeatMap: true, | |
enableCommentPreview: false, | |
enableAutoMylistComment: false, // マイリストコメントに投稿者を入れる | |
menuScale: 1.0, | |
enableTogglePlayOnClick: false, // 画面クリック時に再生/一時停止するかどうか | |
enableDblclickClose: true, // | |
enableFullScreenOnDoubleClick: true, | |
enableStoryboard: true, // シークバーサムネイル関連 | |
enableStoryboardBar: false, // シーンサーチ | |
videoInfoPanelTab: 'videoInfoTab', | |
fullscreenControlBarMode: 'auto', // 'always-show' 'always-hide' | |
enableFilter: true, | |
wordFilter: '', | |
wordRegFilter: '', | |
wordRegFilterFlags: 'i', | |
userIdFilter: '', | |
commandFilter: '', | |
removeNgMatchedUser: false, // NGにマッチしたユーザーのコメント全部消す | |
'filter.fork0': true, // 通常コメント | |
'filter.fork1': true, // 投稿者コメント | |
'filter.fork2': true, // かんたんコメント | |
videoTagFilter: '', | |
videoOwnerFilter: '', | |
enableCommentPanel: true, | |
enableCommentPanelAutoScroll: true, | |
commentSpeedRate: 1.0, | |
autoCommentSpeedRate: false, | |
playlistLoop: false, | |
commentLanguage: 'ja_JP', | |
baseFontFamily: '', | |
baseChatScale: 1.0, | |
baseFontBolder: true, | |
cssFontWeight: 'bold', | |
allowOtherDomain: true, | |
overrideWatchLink: false, // すべての動画リンクをZenzaWatchで開く | |
'overrideWatchLink:others': false, // すべての動画リンクをZenzaWatchで開く | |
speakLark: false, // 一発ネタのコメント読み上げ機能. 飽きたら消す | |
speakLarkVolume: 1.0, // 一発ネタのコメント読み上げ機能. 飽きたら消す | |
enableSingleton: false, | |
loadLinkedChannelVideo: false, | |
commentLayerOpacity: 1.0, // | |
'commentLayer.textShadowType': '', // フォントの修飾タイプ | |
'commentLayer.enableSlotLayoutEmulation': false, | |
'commentLayer.ownerCommentShadowColor': '#008800', // 投稿者コメントの影の色 | |
'commentLayer.easyCommentOpacity': 0.5, // かんたんコメントの透明度 | |
overrideGinza: false, // 動画視聴ページでもGinzaの代わりに起動する | |
enableGinzaSlayer: false, // まだ実験中 | |
lastPlayerId: '', | |
playbackRate: 1.0, | |
lastWatchId: 'sm9', | |
message: '', | |
enableVideoSession: true, | |
videoServerType: 'dmc', | |
autoDisableDmc: true, // smileのほうが高画質と思われる動画でdmcを無効にする | |
dmcVideoQuality: 'auto', // 優先する画質 auto, veryhigh, high, mid, low | |
smileVideoQuality: 'default', // default eco | |
useWellKnownPort: false, // この機能なくなったぽい (常時true相当になった) | |
'video.hls.enable': true, | |
'video.hls.segmentDuration': 6000, | |
'video.hls.enableOnlyRequired': true, // hlsが必須の動画だけ有効化する | |
enableNicosJumpVideo: true, // @ジャンプを有効にするかどうか | |
'videoSearch.ownerOnly': true, | |
'videoSearch.mode': 'tag', | |
'videoSearch.order': 'desc', | |
'videoSearch.sort': 'playlist', | |
'videoSearch.word': '', | |
'uaa.enable': true, | |
'screenshot.prefix': '', // スクリーンショットのファイル名の先頭につける文字 | |
'search.limit': 300, // 検索する最大件数(最大1600) 100件ごとにAPIを叩くので多くするほど遅くなる | |
'touch.enable': window.ontouchstart !== undefined, | |
'touch.tap2command': '', | |
'touch.tap3command': 'toggle-mute', | |
'touch.tap4command': 'toggle-showComment', | |
'touch.tap5command': 'screenShot', | |
'navi.favorite': [], | |
'navi.playlistButtonMode': 'insert', | |
'navi.ownerFilter': false, | |
'navi.lastSearchQuery': '', | |
autoZenTube: false, | |
bestZenTube: false, | |
KEY_CLOSE: 27, // ESC | |
KEY_RE_OPEN: 27 + 0x1000, // SHIFT + ESC | |
KEY_HOME: 36 + 0x1000, // SHIFT + HOME | |
KEY_SEEK_LEFT: 37 + 0x1000, // SHIFT + LEFT | |
KEY_SEEK_RIGHT: 39 + 0x1000, // SHIFT + RIGHT | |
KEY_SEEK_LEFT2: 99999999, // カスタマイズ用 | |
KEY_SEEK_RIGHT2: 99999999, // | |
KEY_SEEK_PREV_FRAME: 188, // , | |
KEY_SEEK_NEXT_FRAME: 190, // . | |
KEY_VOL_UP: 38 + 0x1000, // SHIFT + UP | |
KEY_VOL_DOWN: 40 + 0x1000, // SHIFT + DOWN | |
KEY_INPUT_COMMENT: 67, // C | |
KEY_FULLSCREEN: 70, // F | |
KEY_MUTE: 77, // M | |
KEY_TOGGLE_COMMENT: 86, // V | |
KEY_TOGGLE_LOOP: 82, // R 76, // L | |
KEY_DEFLIST_ADD: 84, // T | |
KEY_DEFLIST_REMOVE: 84 + 0x1000, // SHIFT + T | |
KEY_TOGGLE_PLAY: 32, // SPACE | |
KEY_TOGGLE_PLAYLIST: 80, // P | |
KEY_SCREEN_MODE_1: 49 + 0x1000, // SHIFT + 1 | |
KEY_SCREEN_MODE_2: 50 + 0x1000, // SHIFT + 2 | |
KEY_SCREEN_MODE_3: 51 + 0x1000, // SHIFT + 3 | |
KEY_SCREEN_MODE_4: 52 + 0x1000, // SHIFT + 4 | |
KEY_SCREEN_MODE_5: 53 + 0x1000, // SHIFT + 5 | |
KEY_SCREEN_MODE_6: 54 + 0x1000, // SHIFT + 6 | |
KEY_SHIFT_RESET: 49, // 1 | |
KEY_SHIFT_DOWN: 188 + 0x1000, // < | |
KEY_SHIFT_UP: 190 + 0x1000, // > | |
KEY_NEXT_VIDEO: 74, // J | |
KEY_PREV_VIDEO: 75, // K | |
KEY_SCREEN_SHOT: 83, // S | |
KEY_SCREEN_SHOT_WITH_COMMENT: 83 + 0x1000, // SHIFT + S | |
}; | |
if (navigator && | |
navigator.userAgent && | |
navigator.userAgent.match(/(Android|iPad;|CriOS)/i)) { | |
DEFAULT_CONFIG.overrideWatchLink = true; | |
DEFAULT_CONFIG.enableTogglePlayOnClick = true; | |
DEFAULT_CONFIG.autoFullScreen = true; | |
DEFAULT_CONFIG.autoCloseFullScreen = false; | |
DEFAULT_CONFIG.volume = 1.0; | |
DEFAULT_CONFIG.enableVideoSession = true; | |
DEFAULT_CONFIG['uaa.enable'] = false; | |
} | |
return DataStorage.create( | |
DEFAULT_CONFIG, | |
{ | |
prefix: PRODUCT, | |
ignoreExportKeys: ['message', 'lastPlayerId', 'lastWatchId', 'debug'], | |
readonly: !location || location.host !== 'www.nicovideo.jp', | |
storage: localStorage | |
} | |
); | |
})(); | |
Config.exportConfig = () => Config.export(); | |
Config.importConfig = v => Config.import(v); | |
Config.exportToFile = () => { | |
const json = Config.exportJson(); | |
const blob = new Blob([json], {'type': 'text/html'}); | |
const url = URL.createObjectURL(blob); | |
const a = Object.assign(document.createElement('a'), { | |
download: `${new Date().toLocaleString().replace(/[:/]/g, '_')}_ZenzaWatch.config.json`, | |
rel: 'noopener', | |
href: url | |
}); | |
(document.body || document.documentElemennt).append(a); | |
a.click(); | |
setTimeout(() => a.remove(), 1000); | |
}; | |
const NaviConfig = Config; | |
await Config.promise('restore'); | |
const uQuery = (() => { | |
const endMap = new WeakMap(); | |
const emptyMap = new Map(); | |
const emptySet = new Set(); | |
const elementsEventMap = new WeakMap(); | |
const HAS_CSSTOM = (window.CSS && CSS.number) ? true : false; | |
const toCamel = p => p.replace(/-./g, s => s.charAt(1).toUpperCase()); | |
const toSnake = p => p.replace(/[A-Z]/g, s => `-${s.charAt(1).toLowerCase()}`); | |
const isStyleValue = val => ('px' in CSS) && val instanceof CSSStyleValue; | |
const emitter = new Emitter(); | |
const UNDEF = Symbol('undefined'); | |
const waitForDom = resolve => { | |
if (['interactive', 'complete'].includes(document.readyState)) { | |
return resolve(); | |
} | |
document.addEventListener('DOMContentLoaded', resolve, {once: true}); | |
}; | |
const waitForComplete = resolve => { | |
if (['complete'].includes(document.readyState)) { | |
return resolve(); | |
} | |
window.addEventListener('load', resolve, {once: true}); | |
}; | |
const isTagLiteral = (t,...args) => | |
Array.isArray(t) && | |
Array.isArray(t.raw) && | |
t.length === t.raw.length && | |
args.length === t.length - 1; | |
const templateMap = new WeakMap(); | |
const createDom = (template, ...args) => { | |
const isTL = isTagLiteral(template, ...args); | |
if (isTL && templateMap.has(template)) { | |
const tpl = templateMap.get(template); | |
return document.importNode(tpl.content, true); | |
} | |
const tpl = document.createElement('template'); | |
tpl.innerHTML = isTL ? String.raw(template, ...args) : template; | |
isTL && templateMap.set(template, tpl); | |
return document.importNode(tpl.content, true); | |
}; | |
const walkingHandler = { | |
set: function (target, prop, value) { | |
for (const elm of target) { | |
elm[prop] = value; | |
} | |
return true; | |
}, | |
get: function (target, prop) { | |
const isFunc = target.some(elm => typeof elm[prop] === 'function'); | |
if (!isFunc) { | |
const isObj = target.some(elm => elm[prop] instanceof Object); | |
let result = target.map(elm => typeof elm[prop] === 'function' ? elm[prop].bind(elm) : elm[prop]); | |
return isObj ? result.walk : result; | |
} | |
return (...args) => { | |
let result = target.map((elm, index) => { | |
try { | |
return (typeof elm[prop] === 'function' ? | |
elm[prop].apply(elm, args) : elm[prop]) || elm; | |
} catch (error) { | |
console.warn('Exception: ', {target, prop, index, error}); | |
} | |
}); | |
const isObj = result.some(r => r instanceof Object); | |
return isObj ? result.walk : result; | |
}; | |
} | |
}; | |
const isHTMLElement = elm => { | |
return (elm instanceof HTMLElement) || | |
(elm.ownerDocument && elm instanceof elm.ownerDocument.defaultView.HTMLElement); | |
}; | |
const isNode = elm => { | |
return (elm instanceof Node) || | |
(elm.ownerDocument && elm instanceof elm.ownerDocument.defaultView.Node); | |
}; | |
const isDocument = d => { | |
return (d instanceof Document) || (d && d[Symbol.toStringTag] === 'HTMLDocument') || | |
(d.documentElement && d instanceof d.documentElement.ownerDocument.defaultView.Node); | |
}; | |
const isEventTarget = e => { | |
return (e instanceof EventTarget) || | |
(e[Symbol.toStringTag] === 'EventTarget') || | |
(e.addEventListener && e.removeEventListener && e.dispatchEvent); | |
}; | |
const isHTMLCollection = e => { | |
return e instanceof HTMLCollection || (e && e[Symbol.toStringTag] === 'HTMLCollection'); | |
}; | |
const isNodeList = e => { | |
return e instanceof NodeList || (e && e[Symbol.toStringTag] === 'NodeList'); | |
}; | |
class RafCaller { | |
constructor(elm, methods = []) { | |
this.elm = elm; | |
methods.forEach(method => { | |
const task = elm[method].bind(elm); | |
task._name = method; | |
this[method] = (...args) => { | |
this.enqueue(task, ...args); | |
return elm; | |
}; | |
}); | |
} | |
get promise() { | |
return this.constructor.promise; | |
} | |
enqueue(task, ...args) { | |
this.constructor.taskList.push([task, ...args]); | |
this.constructor.exec(); | |
} | |
cancel() { | |
this.constructor.taskList.length = 0; | |
} | |
} | |
RafCaller.promise = new PromiseHandler(); | |
RafCaller.taskList = []; | |
RafCaller.exec = throttle.raf(function() { | |
const taskList = this.taskList.concat(); | |
this.taskList.length = 0; | |
for (const [task, ...args] of taskList) { | |
try { | |
task(...args); | |
} catch (err) { | |
console.warn('RafCaller task fail', {task, args}); | |
} | |
} | |
this.promise.resolve(); | |
this.promise = new PromiseHandler(); | |
}.bind(RafCaller)); | |
class $Array extends Array { | |
get [Symbol.toStringTag]() { | |
return '$Array'; | |
} | |
get na() /* 先頭の要素にアクセス */ { | |
return this[0]; | |
} | |
get nz() /* 末尾の要素にアクセス */ { | |
return this[this.length - 1]; | |
} | |
get walk() /* 全要素のメソッド・プロパティにアクセス */ { | |
const p = this._walker || new Proxy(this, walkingHandler); | |
this._walker = p; | |
return p; | |
} | |
get array() { | |
return [...this]; | |
} | |
toArray() { | |
return this.array; | |
} | |
constructor(...args) { | |
super(); | |
const elm = args.length > 1 ? args : args[0]; | |
if (isHTMLCollection(elm) || isNodeList(elm)) { | |
for (const e of elm) { | |
super.push(e); | |
} | |
} else if (typeof elm === 'number') { | |
this.length = elm; | |
} else { | |
this[0] = elm; | |
} | |
} | |
get raf() { | |
if (!this._raf) { | |
this._raf = new RafCaller(this, [ | |
'addClass','removeClass','toggleClass','css','setAttribute','attr','data','prop', | |
'val','focus','blur','insert','append','appendChild','prepend','after','before', | |
'text','appendTo','prependTo','remove','show','hide' | |
]); | |
} | |
return this._raf; | |
} | |
get htmls() { | |
return this.filter(isHTMLElement); | |
} | |
*getHtmls() { | |
for (const elm of this) { | |
if (isHTMLElement(elm)) { yield elm; } | |
} | |
} | |
get firstElement() { | |
for (const elm of this) { | |
if (isHTMLElement(elm)) { return elm; } | |
} | |
return null; | |
} | |
get nodes() { | |
return this.filter(isNode); | |
} | |
*getNodes() { | |
for (const n of this) { | |
if (isNode(n)) { yield n; } | |
} | |
} | |
get firstNode() { | |
for (const n of this) { | |
if (isNode(n)) { return n; } | |
} | |
return null; | |
} | |
get independency() { | |
const nodes = this.nodes; | |
if (nodes.length <= 1) { | |
return nodes; | |
} | |
return this.filter(elm => nodes.every(e => e === elm || !e.contains(elm))); | |
} | |
get uniq() { | |
return this.constructor.from([...new Set(this)]); | |
} | |
clone() { | |
return this.constructor.from(this.independency.filter(e => e.cloneNode).map(e => e.cloneNode(true))); | |
} | |
find(query) { | |
if (typeof query !== 'string') { | |
return super.find(query); | |
} | |
return this.query(query); | |
} | |
query(query) { | |
const found = this | |
.independency | |
.filter(elm => elm.querySelectorAll) | |
.map(elm => $Array.from(elm.querySelectorAll(query))) | |
.flat(); | |
endMap.set(found, this); | |
return found; | |
} | |
mapQuery(map) { | |
const $tmp = this | |
.independency | |
.filter(elm => elm.querySelectorAll); | |
const result = [], e = [], $ = {}; | |
for (const key of Object.keys(map)) { | |
const query = map[key]; | |
const found = $tmp.map(elm => $Array.from(elm.querySelectorAll(query))).flat(); | |
result[key] = key.match(/^_?\$/) ? found : found[0]; | |
$[key.replace(/^(_?)/, '$1$')] = found; | |
e[key.replace(/^(_?)\$/, '$1')] = found[0]; | |
} | |
return {result, $, e}; | |
} | |
end() { | |
return endMap.has(this) ? endMap.get(this) : this; | |
} | |
each(callback) { | |
this.htmls.forEach((elm, index) => callback.apply(elm, [index, elm])); | |
} | |
closest(selector) { | |
const result = this.query(elm => elm.closest(selector)); | |
return result ? this.constructor.from(result) : null; | |
} | |
parent() { | |
const found = this | |
.independency | |
.filter(e => e.parentNode).map(e => e.parentNode); | |
return found; | |
} | |
parents(selector) { | |
let h = selector ? this.parent().closest(selector) : this.parent(); | |
const found = [h]; | |
while (h.length) { | |
h = selector ? h.parent().closest(selector) : h.parent(); | |
found.push(h); | |
} | |
return $Array.from(h.flat()); | |
} | |
toggleClass(className, v) { | |
if (typeof v === 'boolean') { | |
return v ? this.addClass(className) : this.removeClass(className); | |
} | |
const classes = className.trim().split(/\s+/); | |
const htmls = this.getHtmls(); | |
for (const elm of htmls) { | |
for (const c of classes) { | |
elm.classList.toggle(c, v); | |
} | |
} | |
return this; | |
} | |
addClass(className) { | |
const names = className.split(/\s+/); | |
const htmls = this.getHtmls(); | |
for (const elm of htmls) { | |
elm.classList.add(...names); | |
} | |
return this; | |
} | |
removeClass(className) { | |
const names = className.split(/\s+/); | |
const htmls = this.getHtmls(); | |
for (const elm of htmls) { | |
elm.classList.remove(...names); | |
} | |
return this; | |
} | |
hasClass(className) { | |
const names = className.trim().split(/[\s]+/); | |
const htmls = this.htmls; | |
return names.every( | |
name => htmls.every(elm => elm.classList.contains(name))); | |
} | |
_css(props) { | |
const htmls = this.getHtmls(); | |
for (const element of htmls) { | |
const style = element.style; | |
const map = element.attributeStyleMap; | |
for (let [key, val] of ((props instanceof Map) ? props : Object.entries(props))) { | |
const isNumber = /^[0-9+.]+$/.test(val); | |
if (isNumber && /(width|height|top|left)$/i.test(key)) { | |
val = HAS_CSSTOM ? CSS.px(val) : `${val}px`; | |
} | |
try { | |
if (HAS_CSSTOM && isStyleValue(val)) { | |
key = toSnake(key); | |
map.set(key, val); | |
} else { | |
key = toCamel(key); | |
style[key] = val; | |
} | |
} catch (err) { | |
console.warn('uQuery.css fail', {key, val, isNumber}); | |
} | |
} | |
} | |
return this; | |
} | |
css(key, val = UNDEF) { | |
if (typeof key === 'string') { | |
if (val !== UNDEF) { | |
return this._css({[key]: val}); | |
} else { | |
const element = this.firstElement; | |
if (HAS_CSSTOM) { | |
return element.attributeStyleMap.get(toSnake(key)); | |
} else { | |
return element.style[toCamel(key)]; | |
} | |
} | |
} else if (key !== null && typeof key === 'object') { | |
return this._css(key); | |
} | |
return this; | |
} | |
on(eventName, callback, options) { | |
if (typeof callback !== 'function') { | |
return this; | |
} | |
eventName = eventName.trim(); | |
const elementEventName = eventName.split('.')[0]; | |
for (const element of this.filter(isEventTarget)) { | |
const elementEvents = elementsEventMap.get(element) || new Map; | |
const listenerSet = elementEvents.get(eventName) || new Set; | |
elementEvents.set(eventName, listenerSet); | |
elementsEventMap.set(element, elementEvents); | |
if (!listenerSet.has(callback)) { | |
listenerSet.add(callback); | |
element.addEventListener(elementEventName, callback, options); | |
} | |
} | |
return this; | |
} | |
click(...args) { | |
if (args.length) { | |
const f = this.firstElement; | |
f && f.click(); | |
return this; | |
} | |
const callback = args.find(a => typeof a === 'function'); | |
const data = args[0] !== callback ? args[0] : null; | |
return this.on('click', e => { | |
data && (e.data = e.data || {}) && Object.assign(e.data, data); | |
callback(e); | |
}); | |
} | |
dblclick(...args) { | |
const callback = args.find(a => typeof a === 'function'); | |
const data = args[0] !== callback ? args[0] : null; | |
return this.on('dblclick', e => { | |
data && (e.data = e.data || {}) && Object.assign(e.data, data); | |
callback(e); | |
}); | |
} | |
off(eventName = UNDEF, callback = UNDEF) { | |
if (eventName === UNDEF) { | |
for (const element of this.filter(isEventTarget)) { | |
const eventListenerMap = elementsEventMap.get(element) || emptyMap; | |
for (const [eventName, listenerSet] of eventListenerMap) { | |
for (const listener of listenerSet) { | |
element.removeEventListener(eventName, listener); | |
} | |
listenerSet.clear(); | |
} | |
eventListenerMap.clear(); | |
elementsEventMap.delete(element); | |
} | |
return this; | |
} | |
eventName = eventName.trim(); | |
const [elementEventName, eventKey] = eventName.split('.'); | |
if (callback === UNDEF) { | |
for (const element of this.filter(isEventTarget)) { | |
const eventListenerMap = elementsEventMap.get(element) || emptyMap; | |
const listenerSet = eventListenerMap.get(eventName) || emptySet; | |
for (const listener of listenerSet) { | |
element.removeEventListener(elementEventName, listener); | |
} | |
listenerSet.clear(); | |
eventListenerMap.delete(eventName); | |
for (const [key] of eventListenerMap) { | |
if ( | |
(!eventKey && key.startsWith(`${elementEventName}.`)) || | |
(!elementEventName && key.endsWith(`.${eventKey}`))) { | |
this.off(key); | |
} | |
} | |
} | |
return this; | |
} | |
for (const element of this.filter(isEventTarget)) { | |
const eventListenerMap = elementsEventMap.get(element) || new Map; | |
eventListenerMap.set(eventName, (eventListenerMap.get(eventName) || new Set)); | |
for (const [key, listenerSet] of eventListenerMap) { | |
if (key !== eventName && !key.startsWith(`${elementEventName}.`)) { | |
continue; | |
} | |
if (!listenerSet.has(callback)) { | |
continue; | |
} | |
listenerSet.delete(callback); | |
element.removeEventListener(elementEventName, callback); | |
} | |
} | |
return this; | |
} | |
_setAttribute(key, val = UNDEF) { | |
const htmls = this.getHtmls(); | |
if (val === null || val === '' || val === UNDEF) { | |
for (const e of htmls) { | |
e.removeAttribute(key); | |
} | |
} else { | |
for (const e of htmls) { | |
e.setAttribute(key, val); | |
} | |
} | |
return this; | |
} | |
setAttribute(key, val = UNDEF) { | |
if (typeof key === 'string') { | |
return this._setAttribute(key, val); | |
} | |
for (const k of Object.keys(key)) { | |
this._setAttribute(k, key[k]); | |
} | |
return this; | |
} | |
attr(key, val = UNDEF) { | |
if (val !== UNDEF || typeof key === 'object') { | |
return this.setAttribute(key, val); | |
} | |
const found = this.find(e => e.hasAttribute && e.hasAttribute(key)); | |
return found ? found.getAttribute(key) : null; | |
} | |
data(key, val = UNDEF) { | |
if (typeof key === 'object') { | |
for (const k of Object.keys(key)) { | |
this.data(k, JSON.stringify(key[k])); | |
} | |
return this; | |
} | |
key = `data-${toSnake(key)}`; | |
if (val !== UNDEF) { | |
return this.setAttribute(key, JSON.stringify(val)); | |
} | |
const found = this.find(e => e.hasAttribute && e.hasAttribute(key)); | |
const attr = found.getAttribute(key); | |
try { | |
return JSON.parse(attr); | |
} catch (e) { | |
return attr; | |
} | |
} | |
prop(key, val = UNDEF) { | |
if (typeof key === 'object') { | |
for (const k of Object.keys(key)) { | |
this.prop(k, key[k]); | |
} | |
return this; | |
} else if (val !== UNDEF) { | |
for (const elm of this) { | |
elm[key] = val; | |
} | |
return this; | |
} else { | |
const found = this.find(e => e.hasOwnProperty(key)); | |
return found ? found[key] : null; | |
} | |
} | |
val(v = UNDEF) { | |
const htmls = this.getHtmls(); | |
for (const elm of htmls) { | |
if (!('value' in elm)) { | |
continue; | |
} | |
if (v === UNDEF) { | |
return elm.value; | |
} else { | |
elm.value = v; | |
} | |
} | |
return v === UNDEF ? '' : this; | |
} | |
hasFocus() { | |
return this.some(e => e === document.activeElement); | |
} | |
focus() { | |
const fe = this.firstElement; | |
if (fe) { | |
fe.focus(); | |
} | |
return this; | |
} | |
blur() { | |
const htmls = this.getHtmls(); | |
for (const elm of htmls) { | |
if (elm === document.activeElement) { | |
elm.blur(); | |
} | |
} | |
return this; | |
} | |
insert(where, ...args) { | |
const fn = this.firstNode; | |
if (!fn) { | |
return this; | |
} | |
if (args.every(a => typeof a === 'string' || isNode(a))) { | |
fn[where](...args); | |
} else { | |
const $d = uQuery(...args); | |
if ($d instanceof $Array) { | |
fn[where](...$d.filter(a => typeof a === 'string' || isNode(a))); | |
} | |
} | |
return this; | |
} | |
append(...args) { | |
return this.insert('append', ...args); | |
} | |
appendChild(...args) { | |
return this.append(...args); | |
} | |
prepend(...args) { | |
return this.insert('prepend', ...args); | |
} | |
after(...args) { | |
return this.insert('after', ...args); | |
} | |
before(...args) { | |
return this.insert('before', ...args); | |
} | |
text(text = UNDEF) { | |
const fn = this.firstNode; | |
if (text !== UNDEF) { | |
fn && (fn.textContent = text); | |
} else { | |
return this.htmls.find(e => e.textContent) || ''; | |
} | |
return this; | |
} | |
appendTo(target) { | |
if (typeof target === 'string') { | |
const e = document.querySelector(target); | |
e && e.append(...this.nodes); | |
} else { | |
target.append(...this.nodes); | |
} | |
return this; | |
} | |
prependTo(target) { | |
if (typeof target === 'string') { | |
const e = document.querySelector(target); | |
e && e.prepend(...this.nodes); | |
} else { | |
target.prepend(...this.nodes); | |
} | |
return this; | |
} | |
remove() { | |
for (const elm of this) { elm.remove && elm.remove(); } | |
return this; | |
} | |
show() { | |
for (const elm of this) { elm.hidden = false; } | |
return this; | |
} | |
hide() { | |
for (const elm of this) { elm.hidden = true; } | |
return this; | |
} | |
shadow(...args) { | |
const elm = this.firstElement; | |
if (!elm) { | |
return this; | |
} | |
if (args.length === 0) { | |
elm.shadowRoot || elm.attachShadow({mode: 'open'}); | |
return $Array(elm.shadowRoot); | |
} | |
const $d = uQuery(...args); | |
if ($d instanceof $Array) { | |
elm.shadowRoot || elm.attachShadow({mode: 'open'}); | |
$d.appendTo(elm.shadowRoot); | |
return $d; | |
} | |
return this; | |
} | |
} | |
const uQuery = (q, ...args) => { | |
const isTL = isTagLiteral(q, ...args); | |
if (isTL || typeof q === 'string') { | |
const query = isTL ? String.raw(q, ...args) : q; | |
return query.startsWith('<') ? | |
new $Array(createDom(q, ...args).children) : | |
new $Array(document.querySelectorAll(query)); | |
} else if (q instanceof Window) { | |
return $Array.from(q.document); | |
} else if (q instanceof $Array) { | |
return q.concat(); | |
} else if (q[Symbol.iterator]) { | |
return $Array.from(q); | |
} else if (isDocument(q)) { | |
return $Array.from(q.documentElement); | |
} else { | |
return new $Array(q); | |
} | |
}; | |
Object.assign(uQuery, { | |
$Array, | |
createDom, | |
html: (...args) => new $Array(createDom(...args).children), | |
isTL: isTagLiteral, | |
ready: (func = () => {}) => emitter.promise('domReady', waitForDom).then(() => func()), | |
complete: (func = () => {}) => emitter.promise('domComplete', waitForComplete).then(() => func()), | |
each: (arr, callback) => Array.from(arr).forEach((a, i) => callback.apply(a, [i, a])), | |
proxy: (func, ...args) => func.bind(...args), | |
fn: { | |
} | |
}); | |
return uQuery; | |
})(); | |
const uq = uQuery; | |
const ZenzaWatch = { | |
version: VER, | |
env: ENV, | |
debug: {WatchInfoCacheDb, StoryboardCacheDb}, | |
api: {}, | |
init: {}, | |
lib: { $: window.ZenzaLib.$ || $, _ }, | |
external: {}, | |
util, | |
modules: {Emitter, Handler}, | |
config: Config, | |
emitter: new Emitter(), | |
state: {}, | |
dll | |
}; | |
Promise.all([//https://unpkg.com/[email protected]/lit-html.js?module | |
dimport('https://unpkg.com/[email protected]/lit-html.js?module'), | |
dimport('https://unpkg.com/[email protected]/directives/repeat?module'), | |
dimport('https://unpkg.com/[email protected]/directives/class-map?module') | |
]).then(([lit, ...directives]) => { | |
dll.lit = lit; | |
dll.directives = Object.assign({}, ...directives); | |
emitter.emitResolve('lit-html', dll.lit); | |
}); | |
const Navi = { | |
version: VER, | |
env: ENV, | |
debug: {}, | |
config: NaviConfig, | |
emitter: new Emitter(), | |
state: {} | |
}; | |
delete window.ZenzaLib; | |
if (location.host.match(/\.nicovideo\.jp$/)) { | |
window.ZenzaWatch = ZenzaWatch; | |
window.Navi = Navi; | |
} else { | |
window.ZenzaWatch = {config: ZenzaWatch.config}; | |
window.Navi = {config: Navi.config}; | |
} | |
window.ZenzaWatch.emitter = ZenzaWatch.emitter = new Emitter(); | |
const debug = ZenzaWatch.debug; | |
const emitter = ZenzaWatch.emitter; | |
// const modules = ZenzaWatch.modules; | |
const CONSTANT = { | |
BASE_Z_INDEX: 100000, | |
CONTROL_BAR_HEIGHT: 40, | |
SIDE_PLAYER_WIDTH: 400, | |
SIDE_PLAYER_HEIGHT: 225, | |
BIG_PLAYER_WIDTH: 896, | |
BIG_PLAYER_HEIGHT: 480, | |
RIGHT_PANEL_WIDTH: 320, | |
BOTTOM_PANEL_HEIGHT: 240, | |
BLANK_VIDEO_URL: '//', | |
BLANK_PNG: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2NgYGD4DwABBAEAcCBlCwAAAABJRU5ErkJggg==', | |
MEDIA_ERROR: { | |
MEDIA_ERR_ABORTED: 1, | |
MEDIA_ERR_NETWORK: 2, | |
MEDIA_ERR_DECODE: 3, | |
MEDIA_ERR_SRC_NOT_SUPPORTED: 4 | |
} | |
}; | |
CONSTANT.BASE_CSS_VARS = (() => { | |
const vars = { | |
'base-bg-color': '#333', | |
'base-fore-color': '#ccc', | |
'light-text-color': '#fff', | |
'scrollbar-bg-color': '#222', | |
'scrollbar-thumb-color': '#666', | |
'item-border-color': '#888', | |
'hatsune-color': '#039393', | |
'enabled-button-color': '#9cf' | |
}; | |
const dt = new Date().toISOString(); | |
vars['scrollbar-thumb-color'] = vars['hatsune-color']; | |
return '#zenzaVideoPlayerDialog, .zenzaRoot {\n' + | |
Object.keys(vars).map(key => `--${key}:${vars[key]};`).join('\n') + | |
'\n}'; | |
})(); | |
CONSTANT.COMMON_CSS = ` | |
${CONSTANT.BASE_CSS_VARS} | |
.xDomainLoaderFrame { | |
border: 0; | |
position: fixed; | |
top: -999px; | |
left: -999px; | |
width: 1px; | |
height: 1px; | |
border: 0; | |
contain: paint; | |
} | |
.ZenButton { | |
display: none; | |
opacity: 0.8; | |
position: absolute; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 100000}; | |
cursor: pointer; | |
font-size: 8pt; | |
width: 32px; | |
height: 26px; | |
padding: 0; | |
line-height: 26px; | |
font-weight: bold; | |
text-align: center; | |
transition: box-shadow 0.2s ease, opacity 0.4s ease; | |
user-select: none; | |
transform: translate(-50%, -50%); | |
contain: layout style; | |
} | |
.ZenButton:hover { | |
opacity: 1; | |
} | |
.ZenButtonInner { | |
background: #eee; | |
color: #000; | |
border: outset 1px; | |
box-shadow: 2px 2px rgba(0, 0, 0, 0.8); | |
} | |
.ZenButton:active .ZenButtonInner { | |
border: inset 1px; | |
transition: translate(2px, 2px); | |
box-shadow: 0 0 rgba(0, 0, 0, 0.8); | |
} | |
.ZenButton.show { | |
display: inline-block; | |
} | |
.zenzaPopupMenu { | |
display: block; | |
position: absolute; | |
background: var(--base-bg-color); | |
color: #fff; | |
overflow: visible; | |
border: 1px solid var(--base-fore-color); | |
padding: 0; | |
opacity: 0.99; | |
box-sizing: border-box; | |
transition: opacity 0.3s ease; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 50000}; | |
user-select: none; | |
} | |
.zenzaPopupMenu:not(.show) { | |
transition: none; | |
visibility: hidden; | |
opacity: 0; | |
pointer-events: none; | |
} | |
.zenzaPopupMenu ul { | |
padding: 0; | |
} | |
.zenzaPopupMenu ul li { | |
position: relative; | |
margin: 2px 4px; | |
white-space: nowrap; | |
cursor: pointer; | |
padding: 2px 8px; | |
list-style-type: none; | |
float: inherit; | |
} | |
.zenzaPopupMenu ul li + li { | |
border-top: 1px dotted var(--item-border-color); | |
} | |
.zenzaPopupMenu li.selected { | |
font-weight: bolder; | |
} | |
.zenzaPopupMenu ul li:hover { | |
background: #663; | |
} | |
.zenzaPopupMenu ul li.separator { | |
border: 1px outset; | |
height: 2px; | |
width: 90%; | |
} | |
.zenzaPopupMenu li span { | |
box-sizing: border-box; | |
margin-left: 8px; | |
display: inline-block; | |
cursor: pointer; | |
} | |
.zenzaPopupMenu ul li.selected span:before { | |
content: '✔'; | |
left: 0; | |
position: absolute; | |
} | |
.zenzaPopupMenu.show { | |
opacity: 0.8; | |
} | |
.zenzaPopupMenu .caption { | |
padding: 2px 4px; | |
text-align: center; | |
margin: 0; | |
font-weight: bolder; | |
background: #666; | |
color: #fff; | |
} | |
.zenzaPopupMenu .triangle { | |
position: absolute; | |
width: 16px; | |
height: 16px; | |
border: 1px solid #ccc; | |
border-width: 0 0 1px 1px; | |
background: #333; | |
box-sizing: border-box; | |
} | |
body.showNicoVideoPlayerDialog #external_nicoplayer { | |
transform: translate(-9999px, 0); | |
} | |
#ZenzaWatchVideoPlayerContainer .atsumori-root { | |
position: absolute; | |
z-index: 10; | |
} | |
#zenzaVideoPlayerDialog.is-guest .forMember { | |
display: none; | |
} | |
#zenzaVideoPlayerDialog .forGuest { | |
display: none; | |
} | |
#zenzaVideoPlayerDialog.is-guest .forGuest { | |
display: inherit; | |
} | |
.scalingUI { | |
transform: scale(var(--zenza-ui-scale)); | |
} | |
`.trim(); | |
CONSTANT.SCROLLBAR_CSS = ` | |
.videoInfoTab::-webkit-scrollbar, | |
#listContainer::-webkit-scrollbar, | |
.zenzaCommentPreview::-webkit-scrollbar, | |
.mylistSelectMenuInner::-webkit-scrollbar { | |
background: var(--scrollbar-bg-color); | |
width: 16px; | |
} | |
.videoInfoTab::-webkit-scrollbar-thumb, | |
#listContainer::-webkit-scrollbar-thumb, | |
.zenzaCommentPreview::-webkit-scrollbar-thumb, | |
.mylistSelectMenuInner::-webkit-scrollbar-thumb { | |
border-radius: 0; | |
background: var(--scrollbar-thumb-color); | |
will-change: transform; | |
} | |
.videoInfoTab::-webkit-scrollbar-button, | |
#listContainer::-webkit-scrollbar-button, | |
.zenzaCommentPreview::-webkit-scrollbar-button, | |
.mylistSelectMenuInner::-webkit-scrollbar-button { | |
display: none; | |
} | |
`.trim(); | |
const global = { | |
emitter, debug, | |
external: ZenzaWatch.external, PRODUCT, TOKEN, CONSTANT, | |
notify: msg => ZenzaWatch.external.execCommand('notify', msg), | |
alert: msg => ZenzaWatch.external.execCommand('alert', msg), | |
config: Config, | |
api: ZenzaWatch.api, | |
innerWidth: window.innerWidth, | |
innerHeight: window.innerHeight, | |
NICORU, | |
dll | |
}; | |
class ClassListWrapper { | |
constructor(element) { | |
this.applyNow = this.apply.bind(this); | |
this.apply = throttle.raf(this.applyNow); | |
if (element) { | |
this.setElement(element); | |
} else { | |
this._next = new Set; | |
this._last = new Set; | |
} | |
} | |
setElement(element) { | |
if (this._element) { | |
this.applyNow(); | |
} | |
this._element = element; | |
this._next = new Set(element.classList); | |
this._last = new Set(this._next); | |
return this; | |
} | |
add(...names) { | |
names = names.map(name => name.trim().split(/\s+/)).flat(); | |
let changed = false; | |
for (const name of names) { | |
if (!this._next.has(name)) { | |
changed = true; | |
this._next.add(name); | |
} | |
} | |
changed && this.apply(); | |
return this; | |
} | |
remove(...names) { | |
names = names.map(name => name.trim().split(/\s+/)).flat(); | |
let changed = false; | |
for (const name of names) { | |
if (this._next.has(name)) { | |
changed = true; | |
this._next.delete(name); | |
} | |
} | |
changed && this.apply(); | |
return this; | |
} | |
contains(name) { | |
return this._next.has(name); | |
} | |
toggle(name, v) { | |
if (v !== undefined) { | |
v = !!v; | |
} else { | |
v = !this.contains(name); | |
} | |
const names = name.trim().split(/\s+/); | |
v ? this.add(...names) : this.remove(...names); | |
return this; | |
} | |
apply() { | |
const last = [...this._last].sort().join(','); | |
const next = [...this._next].sort().join(','); | |
if (next === last) { return; } | |
const element = this._element; | |
const added = [], removed = []; | |
for (const name of this._next) { | |
if (!this._last.has(name)) { added.push(name); } | |
} | |
for (const name of this._last) { | |
if (!this._next.has(name)) { removed.push(name); } | |
} | |
if (removed.length) { element.classList.remove(...removed); } | |
if (added.length) { element.classList.add(...added); } | |
this._last = this._next; | |
this._next = new Set(element.classList); | |
return this; | |
} | |
} | |
const ClassList = function(element) { | |
if (this.map.has(element)) { | |
return this.map.get(element); | |
} | |
const m = new ClassListWrapper(element); | |
this.map.set(element, m); | |
return m; | |
}.bind({map: new WeakMap()}); | |
class domUtil { | |
static create(name, options = {}) { | |
const {dataset, style, ...props} = options; | |
const element = Object.assign(document.createElement(name), props || {}); | |
dataset && Object.assign(element.dataset, dataset); | |
style && Object.assign(element.style, style); | |
return element; | |
} | |
static define(name, classDefinition) { | |
if (!self.customElements) { | |
return false; | |
} | |
if (customElements.get(name)) { | |
return true; | |
} | |
customElements.define(name, classDefinition); | |
return true; | |
} | |
} | |
const reg = (() => { | |
const $ = Symbol('$'); | |
const undef = Symbol.for('undefined'); | |
const MAX_RESULT = 30; | |
const smap = new WeakMap(); | |
const self = {}; | |
const reg = function(regex = undef, str = undef) { | |
const {results, last} = smap.has(this) ? | |
smap.get(this) : {results: [], last: {result: null}}; | |
smap.set(this, {results, last}); | |
if (regex === undef) { | |
return last ? last.result : null; | |
} | |
const regstr = regex.toString(); | |
if (str !== undef) { | |
const found = results.find(r => regstr === r.regstr && str === r.str); | |
return found ? found.result : reg(regex).exec(str); | |
} | |
return { | |
exec(str) { | |
const result = regex.exec(str); | |
Array.isArray(result) && result.forEach((r, i) => result['$' + i] = r); | |
Object.assign(last, {str, regstr, result}); | |
results.push(last); | |
results.length > MAX_RESULT && results.shift(); | |
this[$] = str[$] = regex[$] = result; | |
return result; | |
}, | |
test(str) { return !!this.exec(str); } | |
}; | |
}; | |
const scope = (scopeObj = {}) => reg.bind(scopeObj); | |
return Object.assign(reg.bind(self), {$, scope}); | |
})(); | |
util.reg = reg; | |
const PopupMessage = (() => { | |
const __css__ = ` | |
.zenzaPopupMessage { | |
--notify-color: #0c0; | |
--alert-color: #c00; | |
--shadow-color: #ccc; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 100000}; | |
opacity: 0; | |
display: block; | |
min-width: 150px; | |
margin-bottom: 8px; | |
padding: 8px 16px; | |
white-space: nowrap; | |
font-weight: bolder; | |
overflow-y: hidden; | |
text-align: center; | |
color: rgba(255, 255, 255, 0.8); | |
box-shadow: 2px 2px 0 var(--shadow-color, #ccc); | |
border-radius: 4px; | |
pointer-events: none; | |
user-select: none; | |
animation: zenza-popup-message-animation 5s; | |
animation-fill-mode: forwards; | |
} | |
.zenzaPopupMessage.notify { | |
background: var(--notify-color, #0c0); | |
} | |
.zenzaPopupMessage.alert { | |
background: var(--alert-color, #0c0); | |
} | |
.zenzaPopupMessage.debug { | |
background: #333; | |
} | |
/* できれば広告に干渉したくないけど仕方なく */ | |
div[data-follow-container] { | |
position: static !important; | |
} | |
@keyframes zenza-popup-message-animation { | |
0% { transform: translate3d(0, -100px, 0); opacity: 0; } | |
10% { transform: translate3d(0, 0, 0); } | |
20% { opacity: 0.8; } | |
80% { opacity: 0.8; } | |
90% { opacity: 0; } | |
} | |
`; | |
let initialized = false; | |
const initialize = () => { | |
if (initialized) { return; } | |
initialized = true; | |
css.addStyle(__css__); | |
}; | |
const create = (msg, className, allowHtml = false) => { | |
const d = document.createElement('div'); | |
d.className = `zenzaPopupMessage ${className}`; | |
allowHtml ? (d.innerHTML = msg) : (d.textContent = msg); | |
d.addEventListener('animationend', () => d.remove(), {once: true}); | |
return d; | |
}; | |
const show = msg => { | |
initialize(); | |
const target = document.querySelector('.popupMessageContainer'); | |
(target || document.body).prepend(msg); | |
}; | |
const nt = (msg, allowHtml, type, consoleStyle) => { | |
if (msg === undefined) { | |
msg = '不明なエラー'; | |
window.console.error('undefined message sent'); | |
window.console.trace(); | |
} | |
console.log('%c%s', consoleStyle, msg); | |
show(create(msg, type, allowHtml)); | |
}; | |
const notify = (msg, allowHtml = false) => | |
nt(msg, allowHtml, 'notify', 'background: #080; color: #fff; padding: 8px;'); | |
const alert = (msg, allowHtml = false) => | |
nt(msg, allowHtml, 'alert', 'background: #800; color: #fff; padding: 8px;'); | |
const debug = (msg, allowHtml = false) => | |
nt(msg, allowHtml, 'debug', 'background: #333; color: #fff; padding: 8px;'); | |
return {notify, alert, debug}; | |
})(); | |
const AsyncEmitter = (() => { | |
const emitter = function () { | |
}; | |
emitter.prototype.on = Emitter.prototype.on; | |
emitter.prototype.once = Emitter.prototype.once; | |
emitter.prototype.off = Emitter.prototype.off; | |
emitter.prototype.clear = Emitter.prototype.clear; | |
emitter.prototype.emit = Emitter.prototype.emit; | |
emitter.prototype.emitAsync = Emitter.prototype.emitAsync; | |
return emitter; | |
})(); | |
(ZenzaWatch ? ZenzaWatch.lib : {}).AsyncEmitter = AsyncEmitter; | |
const Fullscreen = { | |
now() { | |
if (document.fullScreenElement || document.mozFullScreen || document.webkitIsFullScreen) { | |
return true; | |
} | |
return false; | |
}, | |
get() { | |
return document.fullScreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || null; | |
}, | |
request(target) { | |
this._handleEvents(); | |
const elm = typeof target === 'string' ? document.getElementById(target) : target; | |
if (!elm) { | |
return; | |
} | |
if (elm.requestFullScreen) { | |
elm.requestFullScreen(); | |
} else if (elm.webkitRequestFullScreen) { | |
elm.webkitRequestFullScreen(); | |
} else if (elm.mozRequestFullScreen) { | |
elm.mozRequestFullScreen(); | |
} | |
}, | |
cancel() { | |
if (!this.now()) { | |
return; | |
} | |
if (document.cancelFullScreen) { | |
document.cancelFullScreen(); | |
} else if (document.webkitCancelFullScreen) { | |
document.webkitCancelFullScreen(); | |
} else if (document.mozCancelFullScreen) { | |
document.mozCancelFullScreen(); | |
} | |
}, | |
_handleEvents() { | |
this._handleEvnets = _.noop; | |
const cl = ClassList(document.body); | |
const handle = () => { | |
const isFull = this.now(); | |
cl.toggle('is-fullscreen', isFull); | |
global.emitter.emit('fullscreenStatusChange', isFull); | |
}; | |
document.addEventListener('webkitfullscreenchange', handle, false); | |
document.addEventListener('mozfullscreenchange', handle, false); | |
document.addEventListener('MSFullscreenChange', handle, false); | |
document.addEventListener('fullscreenchange', handle, false); | |
} | |
}; | |
util.fullscreen = Fullscreen; | |
const dummyConsole = {}; | |
window.console.timeLog || (window.console.timeLog = () => {}); | |
for (const k of Object.keys(window.console)) { | |
if (typeof window.console[k] !== 'function') {continue;} | |
dummyConsole[k] = _.noop; | |
} | |
['assert', 'error', 'warn', 'nicoru'].forEach(k => | |
dummyConsole[k] = window.console[k].bind(window.console)); | |
console = Config.props.debug ? window.console : dummyConsole; | |
Config.onkey('debug', v => console = v ? window.console : dummyConsole); | |
const css = (() => { | |
const setPropsTask = []; | |
const applySetProps = throttle.raf( | |
() => { | |
const tasks = setPropsTask.concat(); | |
setPropsTask.length = 0; | |
for (const [element, prop, value] of tasks) { | |
try { | |
element.style.setProperty(prop, value); | |
} catch (error) { | |
console.warn('element.style.setProperty fail', {element, prop, value, error}); | |
} | |
} | |
}); | |
const css = { | |
addStyle: (styles, option, document = window.document) => { | |
const elm = Object.assign(document.createElement('style'), { | |
type: 'text/css' | |
}, typeof option === 'string' ? {id: option} : (option || {})); | |
if (typeof option === 'string') { | |
elm.id = option; | |
} else if (option) { | |
Object.assign(elm, option); | |
} | |
elm.classList.add(global.PRODUCT); | |
elm.append(styles.toString()); | |
(document.head || document.body || document.documentElement).append(elm); | |
elm.disabled = option && option.disabled; | |
elm.dataset.switch = elm.disabled ? 'off' : 'on'; | |
return elm; | |
}, | |
registerProps(...args) { | |
if (!CSS || !('registerProperty' in CSS)) { | |
return; | |
} | |
for (const definition of args) { | |
try { | |
(definition.window || window).CSS.registerProperty(definition); | |
} catch (err) { console.warn('CSS.registerProperty fail', definition, err); } | |
} | |
}, | |
setProps(...tasks) { | |
setPropsTask.push(...tasks); | |
return setPropsTask.length ? applySetProps() : Promise.resolve(); | |
}, | |
addModule: async function(func, options = {}) { | |
if (!CSS || !('paintWorklet' in CSS) || this.set.has(func)) { | |
return; | |
} | |
this.set.add(func); | |
const src = | |
`(${func.toString()})( | |
this, | |
registerPaint, | |
${JSON.stringify(options.config || {}, null, 2)} | |
);`; | |
const blob = new Blob([src], {type: 'text/javascript'}); | |
const url = URL.createObjectURL(blob); | |
await CSS.paintWorklet.addModule(url).then(() => URL.revokeObjectURL(url)); | |
return true; | |
}.bind({set: new WeakSet}), | |
escape: value => CSS.escape ? CSS.escape(value) : value.replace(/([\.#()[\]])/g, '\\$1'), | |
number: value => CSS.number ? CSS.number(value) : value, | |
s: value => CSS.s ? CSS.s(value) : `${value}s`, | |
ms: value => CSS.ms ? CSS.ms(value) : `${value}ms`, | |
pt: value => CSS.pt ? CSS.pt(value) : `${value}pt`, | |
px: value => CSS.px ? CSS.px(value) : `${value}px`, | |
percent: value => CSS.percent ? CSS.percent(value) : `${value}%`, | |
vh: value => CSS.vh ? CSS.vh(value) : `${value}vh`, | |
vw: value => CSS.vw ? CSS.vw(value) : `${value}vw`, | |
trans: value => self.CSSStyleValue ? CSSStyleValue.parse('transform', value) : value, | |
word: value => self.CSSKeywordValue ? new CSSKeywordValue(value) : value, | |
image: value => self.CSSStyleValue ? CSSStyleValue.parse('background-image', value) : value, | |
}; | |
return css; | |
})(); | |
const cssUtil = css; | |
Object.assign(util, css); | |
const textUtil = { | |
secToTime: sec => { | |
return [ | |
Math.floor(sec / 60).toString().padStart(2, '0'), | |
(Math.floor(sec) % 60).toString().padStart(2, '0') | |
].join(':'); | |
}, | |
parseQuery: (query = '') => { | |
query = query.startsWith('?') ? query.substr(1) : query; | |
const result = {}; | |
query.split('&').forEach(item => { | |
const sp = item.split('='); | |
const key = decodeURIComponent(sp[0]); | |
const val = decodeURIComponent(sp.slice(1).join('=')); | |
result[key] = val; | |
}); | |
return result; | |
}, | |
parseUrl: url => { | |
url = url || 'https://unknown.example.com/'; | |
return Object.assign(document.createElement('a'), {href: url}); | |
}, | |
decodeBase64: str => { | |
try { | |
return decodeURIComponent( | |
escape(atob( | |
str.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(str.length / 4) * 4, '=') | |
))); | |
} catch(e) { | |
return ''; | |
} | |
}, | |
encodeBase64: str => { | |
try { | |
return btoa(unescape(encodeURIComponent(str))); | |
} catch(e) { | |
return ''; | |
} | |
}, | |
escapeHtml: text => { | |
const map = { | |
'&': '&', | |
'\x27': ''', | |
'"': '"', | |
'<': '<', | |
'>': '>' | |
}; | |
return text.replace(/[&"'<>]/g, char => map[char]); | |
}, | |
unescapeHtml: text => { | |
const map = { | |
'&': '&', | |
''': '\x27', | |
'"': '"', | |
'<': '<', | |
'>': '>' | |
}; | |
return text.replace(/(&|'|"|<|>)/g, char => map[char]); | |
}, | |
escapeToZenkaku: text => { | |
const map = { | |
'&': '&', | |
'\'': '’', | |
'"': '”', | |
'<': '<', | |
'>': '>' | |
}; | |
return text.replace(/["'<>]/g, char => map[char]); | |
}, | |
escapeRegs: text => { | |
const match = /[\\^$.*+?()[\]{}|]/g; | |
return text.replace(match, '\\$&'); | |
}, | |
convertKansuEi: text => { | |
let match = /[〇一二三四五六七八九零壱弐惨伍]/g; | |
let map = { | |
'〇': '0', '零': '0', | |
'一': '1', '壱': '1', | |
'二': '2', '弐': '2', | |
'三': '3', '惨': '3', | |
'四': '4', | |
'五': '5', '伍': '5', | |
'六': '6', | |
'七': '7', | |
'八': '8', | |
'九': '9', | |
}; | |
text = text.replace(match, char => map[char]); | |
text = text.replace(/([1-9]?)[十拾]([0-9]?)/g, (n, a, b) => (a && b) ? `${a}${b}` : (a ? a * 10 : 10 + b * 1)); | |
return text; | |
}, | |
dateToString: date => { | |
if (typeof date === 'string') { | |
const origDate = date; | |
date = date.replace(/\//g, '-'); | |
const m = /^(\d+-\d+-\d+) (\d+):(\d+):(\d+)/.exec(date); | |
if (m) { | |
date = new Date(m[1]); | |
date.setHours(m[2]); | |
date.setMinutes(m[3]); | |
date.setSeconds(m[4]); | |
} else { | |
const t = Date.parse(date); | |
if (isNaN(t)) { | |
return origDate; | |
} | |
date = new Date(t); | |
} | |
} else if (typeof date === 'number') { | |
date = new Date(date); | |
} | |
if (!date || isNaN(date.getTime())) { | |
return '1970/01/01 00:00:00'; | |
} | |
const [yy, mm, dd, h, m, s] = [ | |
date.getFullYear(), | |
date.getMonth() + 1, | |
date.getDate(), | |
date.getHours(), | |
date.getMinutes(), | |
date.getSeconds() | |
].map(n => n.toString().padStart(2, '0')); | |
return `${yy}/${mm}/${dd} ${h}:${m}:${s}`; | |
}, | |
isValidJson: data => { | |
try { | |
JSON.parse(data); | |
return true; | |
} catch (e) { | |
return false; | |
} | |
}, | |
toRgba: (c, alpha = 1) => | |
`rgba(${parseInt(c.substr(1, 2), 16)}, ${parseInt(c.substr(3, 2), 16)}, ${parseInt(c.substr(5, 2), 16)}, ${alpha})`, | |
snakeToCamel: snake => snake.replace(/-./g, s => s.charAt(1).toUpperCase()), | |
camelToSnake: (camel, separator = '_') => camel.replace(/([A-Z])/g, s => separator + s.toLowerCase()) | |
}; | |
Object.assign(util, textUtil); | |
const nicoUtil = { | |
parseWatchQuery: query => { | |
try { | |
const result = textUtil.parseQuery(query); | |
const playlist = JSON.parse(textUtil.decodeBase64(result.playlist) || '{}'); | |
if (playlist.searchQuery) { | |
const sq = playlist.searchQuery; | |
if (sq.type === 'tag') { | |
result.playlist_type = 'tag'; | |
result.tag = sq.query; | |
} else { | |
result.playlist_type = 'search'; | |
result.keyword = sq.query; | |
} | |
let [order, sort] = (sq.sort || '+f').split(''); | |
result.order = order === '-' ? 'a' : 'd'; | |
result.sort = sort; | |
if (sq.fRange) { result.f_range = sq.fRange; } | |
if (sq.lRange) { result.l_range = sq.lRange; } | |
} else if (playlist.mylistId) { | |
result.playlist_type = 'mylist'; | |
result.group_id = playlist.mylistId; | |
result.order = | |
document.querySelector('select[name="sort"]') ? | |
document.querySelector('select[name="sort"]').value : '1'; | |
} else if (playlist.id && playlist.id.includes('temporary_mylist')) { | |
result.playlist_type = 'deflist'; | |
result.group_id = 'deflist'; | |
result.order = | |
document.querySelector('select[name="sort"]') ? | |
document.querySelector('select[name="sort"]').value : '1'; | |
} | |
return result; | |
} catch(e) { | |
return {}; | |
} | |
}, | |
hasLargeThumbnail: videoId => { | |
const threthold = 16371888; | |
const cid = videoId.substr(0, 2); | |
const fid = videoId.substr(2) * 1; | |
if (cid === 'nm') { return false; } | |
if (cid !== 'sm' && fid < 35000000) { return false; } | |
if (fid < threthold) { | |
return false; | |
} | |
return true; | |
}, | |
getThumbnailUrlByVideoId: videoId => { | |
const videoIdReg = /^[a-z]{2}\d+$/; | |
if (!videoIdReg.test(videoId)) { | |
return null; | |
} | |
const fileId = parseInt(videoId.substr(2), 10); | |
const large = nicoUtil.hasLargeThumbnail(videoId) ? '.L' : ''; | |
return fileId >= 35374758 ? // このIDから先は新サーバー(おそらく) | |
`https://nicovideo.cdn.nimg.jp/thumbnails/${fileId}/${fileId}.L` : | |
`https://tn.smilevideo.jp/smile?i=${fileId}.${large}`; | |
}, | |
getWatchId: url => { | |
let m; | |
if (url && url.indexOf('nico.ms') >= 0) { | |
m = /\/\/nico\.ms\/([a-z0-9]+)/.exec(url); | |
} else { | |
m = /\/?watch\/([a-z0-9]+)/.exec(url || location.pathname); | |
} | |
return m ? m[1] : null; | |
}, | |
getCommonHeader: () => { | |
try { // hoge?.fuga... はGreasyforkの文法チェックで弾かれるのでまだ使えない | |
return JSON.parse(document.querySelector('#CommonHeader[data-common-header]').dataset.commonHeader || '{}'); | |
} catch (e) { | |
return {initConfig: {}}; | |
} | |
}, | |
isLegacyHeader: () => !document.querySelector('#CommonHeader[data-common-header]'), | |
isPremiumLegacy: () => { | |
const a = 'a[href^="https://account.nicovideo.jp/premium/register"]'; | |
return !document.querySelector(`#topline ${a}, #CommonHeader ${a}`); | |
}, | |
isLoginLegacy: () => { | |
const a = 'a[href^="https://account.nicovideo.jp/login"]'; | |
return !document.querySelector(`#topline ${a}, #CommonHeader ${a}`); | |
}, | |
isPremium: () => | |
nicoUtil.isLegacyHeader() ? nicoUtil.isPremiumLegacy() : | |
!!nicoUtil.getCommonHeader().initConfig.user.isPremium, | |
isLogin: () => | |
nicoUtil.isLegacyHeader() ? nicoUtil.isLoginLegacy() : | |
!!nicoUtil.getCommonHeader().initConfig.user.isLogin, | |
getPageLanguage: () => { | |
try { | |
let h = document.getElementsByClassName('html')[0]; | |
return h.lang || 'ja-JP'; | |
} catch (e) { | |
return 'ja-JP'; | |
} | |
}, | |
openMylistWindow: watchId => { | |
window.open( | |
`//www.nicovideo.jp/mylist_add/video/${watchId}`, | |
'nicomylistadd', | |
'width=500, height=400, menubar=no, scrollbars=no'); | |
}, | |
openTweetWindow: ({watchId, duration, isChannel, title, videoId}) => { | |
const nicomsUrl = `https://nico.ms/${watchId}`; | |
const watchUrl = `https://www.nicovideo.jp/watch/${watchId}`; | |
title = `${title}(${textUtil.secToTime(duration)})`.replace(/@/g, '@ '); | |
const nicoch = isChannel ? ',+nicoch' : ''; | |
const url = | |
'https://twitter.com/intent/tweet?' + | |
'url=' + encodeURIComponent(nicomsUrl) + | |
'&text=' + encodeURIComponent(title) + | |
'&hashtags=' + encodeURIComponent(videoId + nicoch) + | |
'&original_referer=' + encodeURIComponent(watchUrl) + | |
''; | |
window.open(url, '_blank', 'width=550, height=480, left=100, top50, personalbar=0, toolbar=0, scrollbars=1, sizable=1', 0); | |
}, | |
isGinzaWatchUrl: url => /^https?:\/\/www\.nicovideo\.jp\/watch\//.test(url || location.href), | |
getPlayerVer: () => { | |
if (document.getElementById('js-initial-watch-data')) { | |
return 'html5'; | |
} | |
if (document.getElementById('watchAPIDataContainer')) { | |
return 'flash'; | |
} | |
return 'unknown'; | |
}, | |
isZenzaPlayableVideo: () => { | |
try { | |
if (nicoUtil.getPlayerVer() === 'html5') { | |
return true; | |
} | |
const watchApiData = JSON.parse(document.querySelector('#watchAPIDataContainer').textContent); | |
const flvInfo = textUtil.parseQuery( | |
decodeURIComponent(watchApiData.flashvars.flvInfo) | |
); | |
const dmcInfo = JSON.parse( | |
decodeURIComponent(watchApiData.flashvars.dmcInfo || '{}') | |
); | |
const videoUrl = flvInfo.url ? flvInfo.url : ''; | |
const isDmc = dmcInfo && dmcInfo.time; | |
if (isDmc) { | |
return true; | |
} | |
const isSwf = /\/smile\?s=/.test(videoUrl); | |
const isRtmp = (videoUrl.indexOf('rtmp') === 0); | |
return (isSwf || isRtmp) ? false : true; | |
} catch (e) { | |
return false; | |
} | |
}, | |
getNicoHistory: window.decodeURIComponent(document.cookie.replace(/^.*(nicohistory[^;+]).*?/, '')), | |
getMypageVer: () => document.querySelector('#js-initial-userpage-data') ? 'spa' : 'legacy' | |
}; | |
Object.assign(util, nicoUtil); | |
const messageUtil = {}; | |
const WindowMessageEmitter = messageUtil.WindowMessageEmitter = ((safeOrigins = []) => { | |
const emitter = new Emitter(); | |
const knownSource = []; | |
const onMessage = e => { | |
if (!knownSource.includes(e.source) && !safeOrigins.includes(e.origin) | |
) { | |
return; | |
} | |
try { | |
const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; | |
const {id, type, body, sessionId} = data; | |
if (id !== PRODUCT) { | |
return; | |
} | |
const message = body.params; | |
if (type === 'blogParts') { // 互換のための対応 | |
const command = global.config.props.enableSingleton ? | |
(message.command === 'send' ? 'open' : 'send') : message.command; | |
if (command === 'send') { | |
global.external.sendOrExecCommand('open', message.params.watchId); | |
} else { | |
global.external.execCommand('open', message.params.watchId); | |
} | |
return; | |
} else if (body.command !== 'message' || !body.params.command) { | |
return; | |
} | |
emitter.emit('message', message, type, sessionId); | |
} catch (err) { | |
console.error( | |
'%cNicoCommentLayer.Error: window.onMessage - ', | |
'color: red; background: yellow', | |
err, | |
e | |
); | |
console.error('%corigin: ', 'background: yellow;', e.origin); | |
console.error('%cdata: ', 'background: yellow;', e.data); | |
console.trace(); | |
} | |
}; | |
emitter.addKnownSource = win => knownSource.push(win); | |
window.addEventListener('message', onMessage); | |
return emitter; | |
})(['http://ext.nicovideo.jp', 'https://ext.nicovideo.jp']); | |
const BroadcastEmitter = messageUtil.BroadcastEmitter = (() => { | |
const bcast = new Emitter(); | |
bcast.windowId = `${PRODUCT}-${Math.random()}`; | |
const channel = | |
(self.BroadcastChannel && location.host === 'www.nicovideo.jp') ? | |
(new self.BroadcastChannel(PRODUCT)) : null; | |
const onStorage = e => { | |
let command = e.key; | |
if (e.type !== 'storage' || !command.startsWith(`${PRODUCT}_`)) { | |
return; | |
} | |
command = command.replace('ZenzaWatch_', ''); | |
let oldValue = e.oldValue; | |
let newValue = e.newValue; | |
if (oldValue === newValue) { | |
return; | |
} | |
switch (command) { | |
case 'message': { | |
const {body} = JSON.parse(newValue); | |
console.log('%cmessage', 'background: cyan;', body); | |
bcast.emitAsync('message', body, 'broadcast'); | |
break; | |
} | |
} | |
}; | |
const onBroadcastMessage = e => { | |
console.log('%cbcast.onBroadcastMessage', 'background: cyan;', e.data); | |
const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; | |
const {body, sessionId} = data; | |
if (body.command !== 'message' || !body.params.command) { | |
console.warn('unknown broadcast format', body); | |
return; | |
} | |
return bcast.emitAsync('message', body.params, 'broadcast', sessionId); | |
}; | |
bcast.sendExecCommand = body => | |
bcast.sendMessagePromise({command: 'sendExecCommand', params: body}); | |
bcast.sendMessage = (body, sessionId = null) => { | |
const requestId = `request-${Math.random()}`; | |
Object.assign(body, {requestId, windowId: bcast.windowId, now: Date.now()}); | |
const req = {id: PRODUCT, body: {command: 'message', params: body}, sessionId}; | |
if (channel) { | |
channel.postMessage(req); | |
} else if (location.host === 'www.nicovideo.jp') { | |
Config.setValue('message', {body, sessionId}); | |
} else if (location.host !== 'www.nicovideo.jp' && | |
NicoVideoApi && NicoVideoApi.sendMessage) { | |
return NicoVideoApi.sendMessage(body, !!sessionId, sessionId); | |
} | |
}; | |
bcast.sendMessagePromise = (body, timeout = 60000) => { | |
const sessionId = `sendMessage-${PRODUCT}-${Math.random()}`; | |
let timer = null; | |
return bcast.promise(sessionId, async (resolve, reject) => { | |
const result = bcast.sendMessage(body, sessionId); | |
timer = setTimeout(() => { | |
if (!timer) { return; } | |
return reject(`timeout ${timeout}msec. command: ${body.command}`); | |
}, timeout); | |
if (result instanceof Promise) { | |
return resolve(await result); | |
} | |
}).catch(err => bcast.emitReject(sessionId, err)) | |
.finally(() => { | |
timer = clearTimeout(timer); | |
bcast.resetPromise(sessionId); | |
}); | |
}; | |
bcast.pong = result => bcast.sendMessage({command: 'pong', params: result}); | |
bcast.hello = (message = 'こんにちはこんにちは!') => bcast.sendMessagePromise( | |
{command: 'hello', params: { | |
message, from: document.title, url: location.href, now: Date.now(), | |
ssid: `hello-${Math.random()}`, windowId: bcast.windowId | |
} | |
}); | |
bcast.ping = ({timeout, force} = {}) => { | |
timeout = timeout || 500; | |
return new Promise((resolve, reject) => { | |
bcast.sendMessagePromise( | |
{command: 'ping', params: {timeout, force, now: Date.now()}}).then(resolve); | |
window.setTimeout(() => reject(`timeout ${timeout}ms`), timeout); | |
}); | |
}; | |
bcast.sendOpen = (watchId, params) => { | |
bcast.sendMessage({ | |
command: 'openVideo', params: Object.assign({watchId, eventType: 'click'}, params)}); | |
}; | |
bcast.notifyClose = () => bcast.sendMessage({command: 'notifyClose'}); | |
bcast.notifyOpen = playerId => bcast.sendMessage({command: 'notifyOpen', params: {playerId}}); | |
global.debug.hello = bcast.hello; | |
global.debug.ping = ({timeout, force} = {}) => { | |
window.console.time('ping'); | |
return bcast.ping({timeout, force}).then(result => { | |
window.console.timeEnd('ping'); | |
window.console.info('ping result: ok', result); | |
return result; | |
}).catch(result => { | |
window.console.timeEnd('ping'); | |
window.console.error('ping fail: ', result); | |
return result; | |
}); | |
}; | |
if (location.host === 'www.nicovideo.jp') { | |
if (channel) { | |
channel.addEventListener('message', onBroadcastMessage); | |
} else { | |
window.addEventListener('storage', onStorage); | |
} | |
} | |
return bcast; | |
})(); | |
Object.assign(util, messageUtil); | |
const PlayerSession = { | |
session: {}, | |
init(storage) { | |
this.storage = storage; | |
return this; | |
}, | |
save(playingStatus) { | |
this.storage[this.KEY] = JSON.stringify(playingStatus); | |
}, | |
restore() { | |
let ss = {}; | |
try { | |
const data = this.storage[this.KEY]; | |
if (!data) {return ss;} | |
ss = JSON.parse(this.storage[this.KEY]); | |
this.storage.removeItem(this.KEY); | |
} catch (e) { | |
window.console.error('PlayserSession restore fail: ', this.KEY, e); | |
} | |
console.log('lastSession', ss); | |
return ss; | |
}, | |
clear() { this.storage.removeItem(this.KEY); }, | |
hasRecord() { return this.storage.hasOwnProperty(this.KEY); } | |
}; | |
PlayerSession.KEY = `ZenzaWatch_PlayingStatus`; | |
const WatchPageHistory = (() => { | |
if (!window || !window.location) { | |
return { | |
initialize: () => {}, | |
pushHistory: () => {}, | |
pushHistoryAgency: () => {} | |
}; | |
} | |
let originalUrl = window && window.location && window.location.href; | |
let originalTitle = window && window.document && window.document.title; | |
let isOpen = false; | |
let dialog, watchId, path, title; | |
const restore = () => { | |
history.replaceState(null, null, originalUrl); | |
document.title = (isOpen ? '📺' : '') + originalTitle.replace(/^📺/, ''); | |
bouncedRestore.cancel(); | |
}; | |
const bouncedRestore = _.debounce(restore, 30000); | |
const pushHistory = (path, title) => { | |
if (nicoUtil.isGinzaWatchUrl(originalUrl)) { | |
originalUrl = location.href; | |
originalTitle = document.title; | |
} | |
history.replaceState(null, null, path); | |
document.title = (isOpen ? '📺' : '') + title.replace(/^📺/, ''); | |
bouncedRestore(); | |
}; | |
const onVideoInfoLoad = _.debounce(({watchId, title, owner: {name}}) => { | |
if (!watchId || !isOpen) { | |
return; | |
} | |
title = `${title} by ${name} - ${PRODUCT}`; | |
path = `/watch/${watchId}`; | |
if (location.host === 'www.nicovideo.jp') { | |
return pushHistory(path, title); | |
} | |
if (NicoVideoApi && NicoVideoApi.pushHistory) { | |
return NicoVideoApi.pushHistory(path, title); | |
} | |
}); | |
const onDialogOpen = () => isOpen = true; | |
const onDialogClose = () => { | |
isOpen = false; | |
watchId = title = path = null; | |
history.replaceState(null, null, originalUrl); | |
document.title = originalTitle; | |
}; | |
const initialize = _dialog => { | |
if (dialog) { | |
return; | |
} | |
dialog = _dialog; | |
if (location.host === 'www.nicovideo.jp') { | |
dialog.on('close', onDialogClose); | |
} | |
dialog.on('open', onDialogOpen); | |
dialog.on('loadVideoInfo', onVideoInfoLoad); | |
if (location.host !== 'www.nicovideo.jp') { return; } | |
window.addEventListener('beforeunload', restore, {passive: true}); | |
window.addEventListener('error', restore, {passive: true}); | |
window.addEventListener('unhandledrejection', restore, {passive: true}); | |
}; | |
const pushHistoryAgency = async (path, title) => { | |
if (!navigator || !navigator.locks) { | |
pushHistory(path, title); | |
bouncedRestore.cancel(); | |
await new Promise(r => setTimeout(r, 3000)); | |
return restore(); | |
} | |
let lastTitle = document.title; | |
let lastUrl = location.href; | |
await navigator.locks.request('pushHistoryAgency', {ifAvailable: true}, async lock => { | |
if (!lock) { | |
return; | |
} | |
history.replaceState(null, title, path); | |
await new Promise(r => setTimeout(r, 3000)); | |
history.replaceState(null, lastTitle, lastUrl); | |
await new Promise(r => setTimeout(r, 10000)); | |
}); | |
}; | |
return { | |
initialize, | |
pushHistory, | |
pushHistoryAgency | |
}; | |
})(); | |
const env = { | |
hasFlashPlayer() { | |
return !!navigator.mimeTypes['application/x-shockwave-flash']; | |
}, | |
isEdgePC() { | |
return navigator.userAgent.toLowerCase().includes('edge'); | |
}, | |
isFirefox() { | |
return navigator.userAgent.toLowerCase().includes('firefox'); | |
}, | |
isWebkit() { | |
return !this.isEdgePC() && navigator.userAgent.toLowerCase().includes('webkit'); | |
}, | |
isChrome() { | |
return !this.isEdgePC() && navigator.userAgent.toLowerCase().includes('chrome'); | |
} | |
}; | |
Object.assign(util, env); | |
const Clipboard = { | |
copyText: text => { | |
if (navigator.clipboard) { // httpsじゃないと動かない | |
return navigator.clipboard.writeText(text); | |
} | |
let clip = document.createElement('input'); | |
clip.type = 'text'; | |
clip.style.position = 'fixed'; | |
clip.style.left = '-9999px'; | |
clip.value = text; | |
const node = Fullscreen.element || document.body; | |
node.appendChild(clip); | |
clip.select(); | |
document.execCommand('copy'); | |
window.setTimeout(() => clip.remove(), 0); | |
} | |
}; | |
util.copyToClipBoard = Clipboard.copyText; | |
const netUtil = { | |
ajax: params => { | |
if (location.host !== 'www.nicovideo.jp') { | |
return NicoVideoApi.ajax(params); | |
} | |
return $.ajax(params); | |
}, | |
abortableFetch: (url, params) => { | |
params = params || {}; | |
const racers = []; | |
let timer; | |
const timeout = (typeof params.timeout === 'number' && !isNaN(params.timeout)) ? params.timeout : 30 * 1000; | |
if (timeout > 0) { | |
racers.push(new Promise((resolve, reject) => | |
timer = setTimeout(() => timer ? reject({name: 'timeout', message: 'timeout'}) : resolve(), timeout)) | |
); | |
} | |
const controller = window.AbortController ? (new AbortController()) : null; | |
if (controller) { | |
params.signal = controller.signal; | |
} | |
racers.push(fetch(url, params)); | |
return Promise.race(racers) | |
.catch(err => { | |
if (err.name === 'timeout') { | |
if (controller) { | |
controller.abort(); | |
} | |
} | |
return Promise.reject(err.message || err); | |
}).finally(() => timer = null); | |
}, | |
fetch(url, params) { | |
if (location.host !== 'www.nicovideo.jp') { | |
return NicoVideoApi.fetch(url, params); | |
} | |
return this.abortableFetch(url, params); | |
}, | |
jsonp: (() => { | |
let callbackId = 0; | |
const getFuncName = () => `JsonpCallback${callbackId++}`; | |
let cw = null; | |
const getFrame = () => { | |
if (cw) { return cw; } | |
return new Promise(resolve => { | |
const iframe = document.createElement('iframe'); | |
iframe.srcdoc = ` | |
<html><head></head></html> | |
`.trim(); | |
iframe.sandbox = 'allow-same-origin allow-scripts'; | |
Object.assign(iframe.style, { | |
width: '32px', height: '32px', position: 'fixed', left: '-100vw', top: '-100vh', | |
pointerEvents: 'none', overflow: 'hidden' | |
}); | |
iframe.onload = () => { | |
cw = iframe.contentWindow; | |
resolve(cw); | |
}; | |
(document.body || document.documentElement).append(iframe); | |
}); | |
}; | |
const createFunc = async (url, funcName) => { | |
let timeoutTimer = null; | |
const win = await getFrame(); | |
const doc = win.document; | |
const script = doc.createElement('script'); | |
return new Promise((resolve, reject) => { | |
win[funcName] = result => { | |
win.clearTimeout(timeoutTimer); | |
timeoutTimer = null; | |
script.remove(); | |
delete win[funcName]; | |
resolve(result); | |
}; | |
timeoutTimer = win.setTimeout(() => { | |
script.remove(); | |
delete win[funcName]; | |
if (timeoutTimer) { | |
reject(new Error(`jsonp timeout ${url}`)); | |
} | |
}, 30000); | |
script.src = url; | |
doc.head.append(script); | |
}); | |
}; | |
return (url, funcName) => { | |
if (!funcName) { | |
funcName = getFuncName(); | |
} | |
url = `${url}${url.includes('?') ? '&' : '?'}callback=${funcName}`; | |
return createFunc(url, funcName); | |
}; | |
})() | |
}; | |
Object.assign(util, netUtil); | |
const VideoCaptureUtil = (() => { | |
const crossDomainGates = {}; | |
const initializeByServer = (server, fileId) => { | |
if (crossDomainGates[server]) { | |
return crossDomainGates[server]; | |
} | |
const baseUrl = `https://${server}/smile?i=${fileId}`; | |
crossDomainGates[server] = new CrossDomainGate({ | |
baseUrl, | |
origin: `https://${server}/`, | |
type: `storyboard${PRODUCT}_${server.split('.')[0].replace(/-/g, '_')}` | |
}); | |
return crossDomainGates[server]; | |
}; | |
const _toCanvas = (v, width, height) => { | |
const canvas = document.createElement('canvas'); | |
const context = canvas.getContext('2d'); | |
canvas.width = width; | |
canvas.height = height; | |
context.drawImage(v.drawableElement || v, 0, 0, width, height); | |
return canvas; | |
}; | |
const isCORSReadySrc = src => { | |
if (src.indexOf('dmc.nico') >= 0) { | |
return true; | |
} | |
return false; | |
}; | |
const videoToCanvas = video => { | |
const src = video.src; | |
const sec = video.currentTime; | |
const a = document.createElement('a'); | |
a.href = src; | |
const server = a.host; | |
const search = a.search; | |
if (isCORSReadySrc(src)) { | |
return Promise.resolve({canvas: _toCanvas(video, video.videoWidth, video.videoHeight)}); | |
} | |
return new Promise(async (resolve, reject) => { | |
if (!/\?(.)=(\d+)\.(\d+)/.test(search)) { | |
return reject({status: 'fail', message: 'invalid url', url: src}); | |
} | |
const fileId = RegExp.$2; | |
const gate = initializeByServer(server, fileId); | |
const dataUrl = await gate.videoCapture(src, sec); | |
const bin = atob(dataUrl.split(',')[1]); | |
const buf = new Uint8Array(bin.length); | |
for (let i = 0, len = buf.length; i < len; i++) { | |
buf[i] = bin.charCodeAt(i); | |
} | |
const blob = new Blob([buf.buffer], {type: 'image/png'}); | |
const url = URL.createObjectURL(blob); | |
console.info('createObjectUrl', url.length); | |
const img = new Image(); | |
img.src = url; | |
img.decode() | |
.then(() => resolve({canvas: _toCanvas(img, video.videoWidth, video.videoHeight)})) | |
.catch(err => reject(err)) | |
.finally(() => window.setTimeout(() => URL.revokeObjectURL(url), 10000)); | |
}); | |
}; | |
const htmlToSvg = (html, width = 682, height = 384) => { | |
const data = | |
(`<svg xmlns='http://www.w3.org/2000/svg' width='${width}' height='${height}'> | |
<foreignObject width='100%' height='100%'>${html}</foreignObject> | |
</svg>`).trim(); | |
const svg = new Blob([data], {type: 'image/svg+xml;charset=utf-8'}); | |
return {svg, data}; | |
}; | |
const htmlToCanvas = (html, width = 640, height = 360) => { | |
const imageW = height * 16 / 9; | |
const imageH = imageW * 9 / 16; | |
const {svg, data} = htmlToSvg(html); | |
const url = window.URL.createObjectURL(svg); | |
if (!url) { | |
return Promise.reject(new Error('convert svg fail')); | |
} | |
const img = new Image(); | |
img.width = 682; | |
img.height = 384; | |
const canvas = document.createElement('canvas'); | |
const context = canvas.getContext('2d'); | |
canvas.width = width; | |
canvas.height = height; | |
img.src = url; | |
img.decode().then(() => { | |
context.drawImage( | |
img, | |
(width - imageW) / 2, | |
(height - imageH) / 2, | |
imageW, | |
imageH); | |
}).catch(e => { | |
throw new Error('img decode error', e); | |
}).finally(() => window.URL.revokeObjectURL(url)); | |
return {canvas, img}; | |
}; | |
const nicoVideoToCanvas = async ({video, html, minHeight = 1080}) => { | |
let scale = 1; | |
let width = | |
Math.max(video.videoWidth, video.videoHeight * 16 / 9); | |
let height = video.videoHeight; | |
if (height < minHeight) { | |
scale = Math.floor(minHeight / height); | |
width *= scale; | |
height *= scale; | |
} | |
const canvas = document.createElement('canvas'); | |
const ct = canvas.getContext('2d', {alpha: false}); | |
canvas.width = width; | |
canvas.height = height; | |
const {canvas: videoCanvas} = await videoToCanvas(video); | |
ct.fillStyle = 'rgb(0, 0, 0)'; | |
ct.fillRect(0, 0, width, height); | |
ct.drawImage( | |
videoCanvas, | |
(width - video.videoWidth * scale) / 2, | |
(height - video.videoHeight * scale) / 2, | |
video.videoWidth * scale, | |
video.videoHeight * scale | |
); | |
const {canvas: htmlCanvas, img} = await htmlToCanvas(html, width, height); | |
ct.drawImage(htmlCanvas, 0, 0, width, height); | |
return {canvas, img}; | |
}; | |
const saveToFile = (canvas, fileName = 'sample.png') => { | |
const dataUrl = canvas.toDataURL('image/png'); | |
const bin = atob(dataUrl.split(',')[1]); | |
const buf = new Uint8Array(bin.length); | |
for (let i = 0, len = buf.length; i < len; i++) { | |
buf[i] = bin.charCodeAt(i); | |
} | |
const blob = new Blob([buf.buffer], {type: 'image/png'}); | |
const url = window.URL.createObjectURL(blob); | |
const a = document.createElement('a'); | |
window.console.info('download fileName: ', fileName); | |
a.setAttribute('download', fileName); | |
a.setAttribute('href', url); | |
a.setAttribute('rel', 'noopener'); | |
document.body.append(a); | |
a.click(); | |
window.setTimeout(() => { | |
a.remove(); | |
URL.revokeObjectURL(url); | |
}, 2000); | |
}; | |
return { | |
videoToCanvas, | |
htmlToCanvas, | |
nicoVideoToCanvas, | |
saveToFile | |
}; | |
})(); | |
VideoCaptureUtil.capture = function(src, sec) { | |
const func = () => { | |
return new Promise((resolve, reject) => { | |
const v = createVideoElement('capture'); | |
if (!v) { | |
return reject(); | |
} | |
Object.assign(v.style, { | |
width: '64px', | |
height: '36px', | |
position: 'fixed', | |
left: '-100px', | |
top: '-100px' | |
}); | |
v.volume = 0; | |
v.autoplay = false; | |
v.controls = false; | |
v.addEventListener('loadedmetadata', () => v.currentTime = sec, {once: true}); | |
v.addEventListener('error', err => { v.remove(); reject(err); }, {once: true}); | |
const onSeeked = () => { | |
const c = document.createElement('canvas'); | |
c.width = v.videoWidth; | |
c.height = v.videoHeight; | |
const ctx = c.getContext('2d'); | |
ctx.drawImage(v.drawableElement || v, 0, 0); | |
v.remove(); | |
return resolve(c); | |
}; | |
v.addEventListener('seeked', onSeeked, {once: true}); | |
setTimeout(() => {v.remove();reject();}, 30000); | |
document.body.append(v); | |
v.src = src; | |
v.currentTime = sec; | |
}); | |
}; | |
let wait = (this.lastSrc === src && this.wait) ? this.wait : sleep(1000); | |
this.lastSrc = src; | |
let waitTime = 1000; | |
waitTime += src.indexOf('dmc.nico') >= 0 ? 2000 : 0; | |
waitTime += src.indexOf('.m3u8') >= 0 ? 2000 : 0; | |
let resolve, reject; | |
this.wait = new Promise((...args) => [resolve, reject] = args) | |
.then(() => sleep(waitTime)).catch(() => sleep(waitTime * 2)); | |
return wait.then(func) | |
.then(r => { resolve(r); return r; }) | |
.catch(e => { reject(e); return e; }); | |
}.bind({}); | |
VideoCaptureUtil.initCapTube = function() { | |
const iframe = document.querySelector( | |
'#ZenzaWatchVideoPlayerContainer iframe[title^=YouTube]'); | |
if (!iframe) { | |
return null; | |
} | |
if (this.bridge) { | |
return this.bridge; | |
} | |
const cw = iframe.contentWindow; | |
const promises = this.promises; | |
self.addEventListener('message', e => { | |
if (e.source !== cw) { return; } | |
const {id, body, sessionId, status} = e.data; | |
const {command, params} = body; | |
if (id !== 'CapTube') { | |
return; | |
} | |
switch (command) { | |
case 'commandResult': | |
if (promises[sessionId]) { | |
if (status === 'ok') { | |
promises[sessionId].resolve(params.result); | |
} else { | |
promises[sessionId].reject(params.result); | |
} | |
delete promises[sessionId]; | |
} | |
return; | |
} | |
}); | |
const post = (body, options = {}) => { | |
const sessionId = `send:CapTube:${this.sessionId++}`; | |
return new Promise((resolve, reject) => { | |
promises[sessionId] = {resolve, reject}; | |
cw.postMessage({body, sessionId}, location.href, options.transfer); | |
if (typeof options.timeout === 'number') { | |
setTimeout(() => { | |
reject({status: 'fail', message: 'timeout'}); | |
delete promises[sessionId]; | |
}, options.timeout); | |
} | |
}).finally(() => { delete promises[sessionId]; }); | |
}; | |
return this.bridge = {post}; | |
}.bind({promises: {}, sessionId: 1, bridge: null}); | |
VideoCaptureUtil.capTube = ({title, videoId, author}) => { | |
const tube = VideoCaptureUtil.initCapTube(); | |
if (!tube) { return; } | |
const command = 'capTube'; | |
tube.post({command, params: {title, videoId, author}}, {timeout: 30000}); | |
}; | |
VideoCaptureUtil.capTubeThumbnail = (width = 320, height = 180, type = 'image/webp') => { | |
const tube = VideoCaptureUtil.initCapTube(); | |
if (!tube) { return; } | |
const command = 'capTubeThumbnail'; | |
tube.post({command, params: {width, height, type}}, {timeout: 30000}); | |
}; | |
util.videoCapture = VideoCaptureUtil.capture; | |
util.capTube = VideoCaptureUtil.capTube; | |
const saveMymemory = (player, videoInfo) => { | |
const info = (` | |
<div> | |
<h2>${videoInfo.title}</h2> | |
<a href="//www.nicovideo.jp/watch/${videoInfo.watchId}?from=${Math.floor(player.currentTime)}">元動画</a><br> | |
作成環境: ${navigator.userAgent}<br> | |
作成日: ${(new Date()).toLocaleString()}<br> | |
ZenzaWatch: ver${ZenzaWatch.version} (${ZenzaWatch.env})<br> | |
<button | |
onclick="document.body.classList.toggle('debug');return false;"> | |
デバッグON/OFF | |
</button> | |
</div> | |
`).trim(); | |
const title = `${videoInfo.watchId} - ${videoInfo.title}`; // titleはエスケープされてる | |
const html = player.getMymemory() | |
.replace(/<title>(.*?)<\/title>/, `<title>${title}</title>`) | |
.replace(/(<body.*?>)/, '$1' + info); | |
const blob = new Blob([html], {'type': 'text/html'}); | |
const url = URL.createObjectURL(blob); | |
const a = Object.assign(document.createElement('a'), { | |
download: `${title}.html`, | |
href: url, | |
rel: 'noopener' | |
}); | |
document.body.append(a); | |
a.click(); | |
window.setTimeout(() => { | |
a.remove(); | |
URL.revokeObjectURL(url); | |
}, 1000); | |
}; | |
util.saveMymemory = saveMymemory; | |
class speech { | |
static async speak(text, option = {}) { | |
if (!window.speechSynthesis) { | |
return Promise.resolve(); | |
} | |
const msg = new window.SpeechSynthesisUtterance(); | |
['lang', 'pitch', 'rate', 'voice', 'volume'].forEach(prop => { | |
option.hasOwnProperty(prop) && (msg[prop] = option[prop]); | |
}); | |
if (window.speechSynthesis.speaking) { | |
window.speechSynthesis.cancel(); | |
} else { | |
await this.promise; | |
} | |
return this.promise = new Promise(res => { | |
msg.addEventListener('end', res, {once: true}); | |
msg.addEventListener('error', res, {once: true}); | |
msg.text = text; | |
window.speechSynthesis.speak(msg); | |
}); | |
} | |
static voices(lang) { | |
if (!window.speechSynthesis) { | |
return []; | |
} | |
this._voices = this._voices || window.speechSynthesis.getVoices(); | |
return lang ? this._voices.filter(v => v.lang === lang) : this._voices; | |
} | |
} | |
speech.promise = Promise.resolve(); | |
util.speak = speech.speak; | |
const watchResize = (target, callback) => { | |
if (window.ResizeObserver) { | |
const ro = new window.ResizeObserver(entries => { | |
for (let entry of entries) { | |
if (entry.target === target) { | |
callback(); | |
return; | |
} | |
} | |
}); | |
ro.observe(target); | |
return; | |
} | |
const iframe = document.createElement('iframe'); | |
iframe.loading = 'eager'; | |
iframe.className = 'resizeObserver'; | |
Object.assign(iframe.style, { | |
width: '100%', | |
height: '100%', | |
position: 'absolute', | |
pointerEvents: 'none', | |
border: 0, | |
opacity: 0 | |
}); | |
target.parentElement.append(iframe); | |
iframe.contentWindow.addEventListener('resize', () => { | |
callback(); | |
}); | |
}; | |
util.watchResize = watchResize; | |
util.sortedLastIndex = (arr, value) => { | |
let head = 0; | |
let tail = arr.length; | |
while (head < tail) { | |
let p = Math.floor((head + tail) / 2); | |
let v = arr[p]; | |
if (v <= value) { | |
head = p + 1; | |
} else { | |
tail = p; | |
} | |
} | |
return tail; | |
}; | |
const createVideoElement = (...args) => { | |
if (window.ZenzaHLS && window.ZenzaHLS.createVideoElement) { | |
return window.ZenzaHLS.createVideoElement(...args); | |
} else | |
if (ZenzaWatch.debug.createVideoElement) { | |
return ZenzaWatch.debug.createVideoElement(...args); | |
} | |
return document.createElement('video'); | |
}; | |
util.createVideoElement = createVideoElement; | |
const domEvent = { | |
dispatchCustomEvent(elm, name, detail = {}, options = {}) { | |
const ev = new CustomEvent(name, Object.assign({detail}, options)); | |
elm.dispatchEvent(ev); | |
}, | |
dispatchCommand(element, command, param, originalEvent = null) { | |
return element.dispatchEvent(new CustomEvent('command', | |
{detail: {command, param, originalEvent}, bubbles: true, composed: true} | |
)); | |
}, | |
bindCommandDispatcher(element, command) { | |
element.addEventListener(command, e => { | |
const target = e.target.closest('[data-command]'); | |
if (!target) { | |
global.emitter.emitAsync('hideHover'); | |
return; | |
} | |
let [command, param, type] = target.dataset; | |
if (['number', 'boolean', 'json'].includes(type)) { | |
param = JSON.parse(param); | |
} | |
e.preventDefault(); | |
return this.dispatchCommand(element, command, param, e); | |
}); | |
} | |
}; | |
Object.assign(util, domEvent); | |
util.defineElement = domUtil.defineElement; | |
util.$ = uQuery; | |
util.createDom = util.$.html; | |
util.isTL = util.$.isTL; | |
class ShortcutKeyEmitter { | |
static create(config, element, externalEmitter) { | |
const emitter = new Emitter(); | |
let isVerySlow = false; | |
const map = { | |
CLOSE: 0, | |
RE_OPEN: 0, | |
HOME: 0, | |
SEEK_LEFT: 0, | |
SEEK_RIGHT: 0, | |
SEEK_LEFT2: 0, | |
SEEK_RIGHT2: 0, | |
SEEK_PREV_FRAME: 0, | |
SEEK_NEXT_FRAME: 0, | |
VOL_UP: 0, | |
VOL_DOWN: 0, | |
INPUT_COMMENT: 0, | |
FULLSCREEN: 0, | |
MUTE: 0, | |
TOGGLE_COMMENT: 0, | |
TOGGLE_LOOP: 0, | |
DEFLIST_ADD: 0, | |
DEFLIST_REMOVE: 0, | |
TOGGLE_PLAY: 0, | |
TOGGLE_PLAYLIST: 0, | |
SCREEN_MODE_1: 0, | |
SCREEN_MODE_2: 0, | |
SCREEN_MODE_3: 0, | |
SCREEN_MODE_4: 0, | |
SCREEN_MODE_5: 0, | |
SCREEN_MODE_6: 0, | |
SHIFT_RESET: 0, | |
SHIFT_DOWN: 0, | |
SHIFT_UP: 0, | |
NEXT_VIDEO: 0, | |
PREV_VIDEO: 0, | |
SCREEN_SHOT: 0, | |
SCREEN_SHOT_WITH_COMMENT: 0 | |
}; | |
Object.keys(map).forEach(key => { | |
map[key] = parseInt(config.props[`KEY_${key}`], 10); | |
}); | |
const onKeyDown = e => { | |
const target = (e.path && e.path[0]) ? e.path[0] : e.target; | |
if (target.tagName === 'SELECT' || | |
target.tagName === 'INPUT' || | |
target.tagName === 'TEXTAREA') { | |
return; | |
} | |
const keyCode = e.keyCode + | |
(e.metaKey ? 0x1000000 : 0) + | |
(e.altKey ? 0x100000 : 0) + | |
(e.ctrlKey ? 0x10000 : 0) + | |
(e.shiftKey ? 0x1000 : 0); | |
let key = ''; | |
let param = ''; | |
switch (keyCode) { | |
case 178: | |
case 179: | |
key = 'TOGGLE_PLAY'; | |
break; | |
case 177: | |
key = 'PREV_VIDEO'; | |
break; | |
case 176: | |
key = 'NEXT_VIDEO'; | |
break; | |
case map.CLOSE: | |
key = 'ESC'; | |
break; | |
case map.RE_OPEN: | |
key = 'RE_OPEN'; | |
break; | |
case map.HOME: | |
key = 'SEEK_TO'; | |
param = 0; | |
break; | |
case map.SEEK_LEFT2: | |
key = 'SEEK_BY'; | |
param = isVerySlow ? -0.5 : -5; | |
break; | |
case map.SEEK_LEFT: | |
case 37: // LEFT | |
if (e.shiftKey || isVerySlow) { | |
key = 'SEEK_BY'; | |
param = isVerySlow ? -0.5 : -5; | |
} | |
break; | |
case map.VOL_UP: | |
key = 'VOL_UP'; | |
break; | |
case map.SEEK_RIGHT2: | |
key = 'SEEK_BY'; | |
param = isVerySlow ? 0.5 : 5; | |
break; | |
case map.SEEK_RIGHT: | |
case 39: // RIGHT | |
if (e.shiftKey || isVerySlow) { | |
key = 'SEEK_BY'; | |
param = isVerySlow ? 0.5 : 5; | |
} | |
break; | |
case map.SEEK_PREV_FRAME: | |
key = 'SEEK_PREV_FRAME'; | |
break; | |
case map.SEEK_NEXT_FRAME: | |
key = 'SEEK_NEXT_FRAME'; | |
break; | |
case map.VOL_DOWN: | |
key = 'VOL_DOWN'; | |
break; | |
case map.INPUT_COMMENT: | |
key = 'INPUT_COMMENT'; | |
break; | |
case map.FULLSCREEN: | |
key = 'FULL'; | |
break; | |
case map.MUTE: | |
key = 'MUTE'; | |
break; | |
case map.TOGGLE_COMMENT: | |
key = 'VIEW_COMMENT'; | |
break; | |
case map.TOGGLE_LOOP: | |
key = 'TOGGLE_LOOP'; | |
break; | |
case map.DEFLIST_ADD: | |
key = 'DEFLIST'; | |
break; | |
case map.DEFLIST_REMOVE: | |
key = 'DEFLIST_REMOVE'; | |
break; | |
case map.TOGGLE_PLAY: | |
key = 'TOGGLE_PLAY'; | |
break; | |
case map.TOGGLE_PLAYLIST: | |
key = 'TOGGLE_PLAYLIST'; | |
break; | |
case map.SHIFT_RESET: | |
key = 'PLAYBACK_RATE'; | |
isVerySlow = true; | |
param = 0.1; | |
break; | |
case map.SCREEN_MODE_1: | |
key = 'SCREEN_MODE'; | |
param = 'small'; | |
break; | |
case map.SCREEN_MODE_2: | |
key = 'SCREEN_MODE'; | |
param = 'sideView'; | |
break; | |
case map.SCREEN_MODE_3: | |
key = 'SCREEN_MODE'; | |
param = '3D'; | |
break; | |
case map.SCREEN_MODE_4: | |
key = 'SCREEN_MODE'; | |
param = 'normal'; | |
break; | |
case map.SCREEN_MODE_5: | |
key = 'SCREEN_MODE'; | |
param = 'big'; | |
break; | |
case map.SCREEN_MODE_6: | |
key = 'SCREEN_MODE'; | |
param = 'wide'; | |
break; | |
case map.NEXT_VIDEO: | |
key = 'NEXT_VIDEO'; | |
break; | |
case map.PREV_VIDEO: | |
key = 'PREV_VIDEO'; | |
break; | |
case map.SHIFT_DOWN: | |
key = 'SHIFT_DOWN'; | |
break; | |
case map.SHIFT_UP: | |
key = 'SHIFT_UP'; | |
break; | |
case map.SCREEN_SHOT: | |
key = 'SCREEN_SHOT'; | |
break; | |
case map.SCREEN_SHOT_WITH_COMMENT: | |
key = 'SCREEN_SHOT_WITH_COMMENT'; | |
break; | |
default: | |
break; | |
} | |
if (key) { | |
emitter.emit('keyDown', key, e, param); | |
} | |
}; | |
const onKeyUp = e => { | |
const target = (e.path && e.path[0]) ? e.path[0] : e.target; | |
if (target.tagName === 'SELECT' || | |
target.tagName === 'INPUT' || | |
target.tagName === 'TEXTAREA') { | |
return; | |
} | |
let key = ''; | |
const keyCode = e.keyCode + | |
(e.metaKey ? 0x1000000 : 0) + | |
(e.altKey ? 0x100000 : 0) + | |
(e.ctrlKey ? 0x10000 : 0) + | |
(e.shiftKey ? 0x1000 : 0); | |
let param = ''; | |
switch (keyCode) { | |
case map.SHIFT_RESET: | |
key = 'PLAYBACK_RATE'; | |
isVerySlow = false; | |
param = 1; | |
break; | |
} | |
if (key) { | |
emitter.emit('keyUp', key, e, param); | |
} | |
}; | |
(async () => { | |
await externalEmitter.promise('init'); | |
element = element || document.body || document.documentElement; | |
element.addEventListener('keydown', onKeyDown); | |
element.addEventListener('keyup', onKeyUp); | |
externalEmitter.on('keydown', onKeyDown); | |
externalEmitter.on('keyup', onKeyUp); | |
})(); | |
return emitter; | |
} | |
} | |
class RequestAnimationFrame { | |
constructor(callback, frameSkip) { | |
this._frameSkip = Math.max(0, typeof frameSkip === 'number' ? frameSkip : 0); | |
this._frameCount = 0; | |
this._callback = callback; | |
this._enable = false; | |
this._onFrame = this._onFrame.bind(this); | |
this._isOnce = false; | |
this._isBusy = false; | |
} | |
_onFrame() { | |
if (!this._enable || this._isBusy) { | |
this._requestId = null; | |
return; | |
} | |
this._isBusy = true; | |
this._frameCount++; | |
if (this._frameCount % (this._frameSkip + 1) === 0) { | |
this._callback(); | |
} | |
if (this._isOnce) { | |
return this.disable(); | |
} | |
this.callRaf(); | |
} | |
async callRaf() { | |
await sleep.resolve; | |
this._requestId = requestAnimationFrame(this._onFrame); | |
this._isBusy = false; | |
} | |
enable() { | |
if (this._enable) { | |
return; | |
} | |
this._enable = true; | |
this._isBusy = false; | |
this._requestId && cancelAnimationFrame(this._requestId); | |
this._requestId = requestAnimationFrame(this._onFrame); | |
} | |
disable() { | |
this._enable = false; | |
this._isOnce = false; | |
this._isBusy = false; | |
this._requestId && cancelAnimationFrame(this._requestId); | |
this._requestId = null; | |
} | |
execOnce() { | |
if (this._enable) { | |
return; | |
} | |
this._isOnce = true; | |
this.enable(); | |
} | |
} | |
util.RequestAnimationFrame = RequestAnimationFrame; | |
class FrameLayer { | |
constructor(params) { | |
this.promise = new PromiseHandler(); | |
this.container = params.container; | |
this._initialize(params); | |
this._isVisible = null; | |
this.intersectionObserver = new IntersectionObserver(entries => { | |
const win = this.contentWindow; | |
const isVisible = entries[0].isIntersecting; | |
if (this._isVisible !== isVisible) { | |
this._isVisible = win.isVisible = isVisible; | |
this.iframe.dispatchEvent(new CustomEvent('visibilitychange', {detail: {isVisible, name: win.name}})); | |
} | |
}); | |
} | |
get isVisible() { | |
return this._isVisible; | |
} | |
get frame() { | |
return this.iframe; | |
} | |
wait() { | |
return this.promise; | |
} | |
_initialize(params) { | |
const iframe = this._getIframe(); | |
iframe.className = params.className || ''; | |
iframe.loading = 'eager'; | |
const onload = () => { | |
iframe.onload = null; | |
this.iframe = iframe; | |
const contentWindow = this.contentWindow = iframe.contentWindow; | |
this.intersectionObserver.observe(iframe); | |
this.bridgeFunc = e => { | |
this.iframe.dispatchEvent(new e.constructor(this.iframe, e)); | |
}; | |
this.promise.resolve(contentWindow); | |
}; | |
const html = this._html = params.html; | |
this.container.append(iframe); | |
if ('srcdoc' in iframe.constructor.prototype) { | |
iframe.onload = onload; | |
iframe.srcdoc = html; | |
} else { | |
const d = iframe.contentWindow.document; | |
d.open(); | |
d.write(html); | |
d.close(); | |
window.setTimeout(onload, 0); | |
} | |
} | |
_getIframe() { | |
const iframe = Object.assign(document.createElement('iframe'), { | |
loading: 'eager', srcdoc: '<html></html>', sandbox: 'allow-same-origin allow-scripts' | |
}); | |
return iframe; | |
} | |
addEventBridge(name, options) { | |
this.wait().then(w => w.addEventListener(name, this.bridgeFunc, options)); | |
return this; | |
} | |
removeEventBridge(name) { | |
this.wait().then(w => w.removeEventListener(name, this.bridgeFunc)); | |
return this; | |
} | |
} | |
const MylistPocketDetector = (() => { | |
const promise = | |
(window.MylistPocket && window.MylistPocket.isReady) ? | |
Promise.resolve(window.MylistPocket) : | |
new Promise(resolve => { | |
[window, (document.body || document.documentElement)] | |
.forEach(e => e.addEventListener('MylistPocketInitialized', () => { | |
resolve(window.MylistPocket); | |
}, {once: true})); | |
}); | |
return {detect: () => promise}; | |
})(); | |
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(params) { | |
const {parentNode, name, template, css: style, shadow} = params; | |
const tplId = `${PRODUCT}${name}Template`; | |
let tpl = BaseViewComponent[tplId]; | |
if (!tpl) { | |
if (style) { | |
cssUtil.addStyle(style, `${name}Style`); | |
} | |
tpl = document.createElement('template'); | |
tpl.innerHTML = template; | |
tpl.id = tplId; | |
BaseViewComponent[tplId] = tpl; | |
} | |
const onClick = this._bound.onClick = this._onClick.bind(this); | |
const view = document.importNode(tpl.content, true); | |
this._view = view.querySelector('*') || document.createDocumentFragment(); | |
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'}) { | |
const tplId = `${PRODUCT}${name}Shadow`; | |
let tpl = BaseViewComponent[tplId]; | |
if (!tpl) { | |
tpl = document.createElement('template'); | |
tpl.innerHTML = shadow; | |
tpl.id = tplId; | |
BaseViewComponent[tplId] = 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.append(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(); | |
cssUtil.addStyle(style.innerHTML, `${name}Shadow`); | |
host.append(node); | |
this._shadow = this._shadowRoot = host.querySelector('.root'); | |
this._isDummyShadow = true; | |
} | |
setState(key, val) { | |
if (typeof key === 'string') { | |
return this._setState(key, val); | |
} | |
for (const k of Object.keys(key)) { | |
this._setState(k, key[k]); | |
} | |
} | |
_setState(key, val) { | |
let m; | |
if (this._state[key] !== val) { | |
this._state[key] = val; | |
if ((m = (/^is(.*)$/.exec(key))) !== null) { | |
this.toggleClass(`is-${m[1]}`, !!val); | |
} | |
this.emit('update', {key, val}); | |
} | |
} | |
_onClick(e) { | |
const target = e.target.closest('[data-command]'); | |
if (!target) { | |
return; | |
} | |
let {command, type = 'string', param} = target.dataset; | |
e.stopPropagation(); | |
e.preventDefault(); | |
if (type !== 'string') { param = JSON.parse(param); } | |
this._onCommand(command, param); | |
} | |
appendTo(parentNode) { | |
if (!parentNode) { | |
return; | |
} | |
this._parentNode = parentNode; | |
parentNode.appendChild(this._view); | |
} | |
_onCommand(command, param) { | |
this.dispatchCommand(command, param); | |
} | |
dispatchCommand(command, param) { | |
this._view.dispatchEvent(new CustomEvent('command', | |
{detail: {command, param}, bubbles: true, composed: true} | |
)); | |
} | |
toggleClass(className, v) { | |
const vc = ClassList(this._view); | |
const sc = this._shadow ? ClassList(this._shadow) : null; | |
(className || '').trim().split(/\s+/).forEach(c => { | |
vc.toggle(c, v); | |
if (sc) { | |
sc.toggle(c, vc.contains(c)); | |
} | |
}); | |
} | |
addClass(name) { | |
const names = name.trim().split(/[\s]+/); | |
ClassList(this._view).add(...names); | |
this._shadow && ClassList(this._shadow).add(...names); | |
} | |
removeClass(name) { | |
const names = name.trim().split(/[\s]+/); | |
ClassList(this._view).remove(...names); | |
this._shadow && ClassList(this._shadow).remove(...names); | |
} | |
} | |
class StyleSwitcher { | |
static update({on, off, document = window.document}) { | |
if (on) { | |
Array.from(document.head.querySelectorAll(on)) | |
.forEach(s => { s.disabled = false; s.dataset.switch = 'on'; }); | |
} | |
if (off) { | |
Array.from(document.head.querySelectorAll(off)) | |
.forEach(s => { s.disabled = true; s.dataset.switch = 'off'; }); | |
} | |
} | |
static addClass(selector, ...classNames) { | |
classNames.forEach(name => { | |
Array.from(document.head.querySelectorAll(`${selector}.${name}`)) | |
.forEach(s => { s.disabled = false; s.dataset.switch = 'on'; }); | |
}); | |
} | |
static removeClass(selector, ...classNames) { | |
classNames.forEach(name => { | |
Array.from(document.head.querySelectorAll(`${selector}.${name}`)) | |
.forEach(s => { s.disabled = true; s.dataset.switch = 'off'; }); | |
}); | |
} | |
static toggleClass(selector, className, v) { | |
Array.from(document.head.querySelectorAll(`${selector}.${className}`)) | |
.forEach(s => { s.disabled = v === undefined ? !s.disabled : !v; s.dataset.switch = s.disabled ? 'off' : 'on'; }); | |
} | |
} | |
util.StyleSwitcher = StyleSwitcher; | |
util.dimport = dimport; | |
const VideoItemObserver = (() => { | |
let intersectionObserver; | |
const mutationMap = new WeakMap(); | |
const onItemInview = async (item, watchId) => { | |
const result = await ThumbInfoLoader.load(watchId).catch(() => null); | |
item.classList.remove('is-fetch-current'); | |
if (!result || result.status === 'fail' || result.code === 'DELETED') { | |
if (result && result.code !== 'COMMUNITY') { | |
} | |
item.classList.add('is-fetch-failed', (result) ? result.code : 'is-no-data'); | |
} else { | |
item.dataset.thumbInfo = JSON.stringify(result); | |
} | |
}; | |
const initIntersectionObserver = onItemInview => { | |
if (intersectionObserver) { | |
return intersectionObserver; | |
} | |
const _onInview = item => { | |
item.classList.add('is-fetch-current'); | |
onItemInview(item, item.dataset.videoId); | |
}; | |
intersectionObserver = new window.IntersectionObserver(entries => { | |
entries.filter(entry => entry.isIntersecting).forEach(entry => { | |
const item = entry.target; | |
intersectionObserver.unobserve(item); | |
_onInview(item); | |
}); | |
}, { rootMargin: '200px' }); | |
return intersectionObserver; | |
}; | |
const initMutationObserver = ({query, container}) => { | |
let mutationObserver = mutationMap.get(container); | |
if (mutationObserver) { | |
return mutationObserver; | |
} | |
const update = () => { | |
const items = (container || document).querySelectorAll(query); | |
if (!items || items.length < 1) { return; } | |
if (!items || items.length < 1) { return; } | |
for (const item of items) { | |
if (item.classList.contains('is-fetch-ignore')) { continue; } | |
item.classList.add('is-fetch-wait'); | |
intersectionObserver.observe(item); | |
} | |
}; | |
const onUpdate = _.throttle(update, 1000); | |
mutationObserver = new MutationObserver(mutations => { | |
const isAdded = mutations.find( | |
mutation => mutation.addedNodes && mutation.addedNodes.length > 0); | |
if (isAdded) { onUpdate(); } | |
}); | |
mutationObserver.observe( | |
container, | |
{childList: true, characterData: false, attributes: false, subtree: true} | |
); | |
mutationMap.set(container, mutationObserver); | |
return mutationObserver; | |
}; | |
const observe = ({query, container} = {}) => { | |
if (!window.IntersectionObserver || !window.MutationObserver) { | |
return; | |
} | |
if (!container) { | |
return; | |
} | |
query = query || 'zenza-video-item'; | |
initIntersectionObserver(onItemInview); | |
initMutationObserver({query, container}); | |
}; | |
const unobserve = ({container}) => { | |
let mutationObserver = mutationMap.get(container); | |
if (!mutationObserver) { | |
return; | |
} | |
mutationObserver.disconnect(); | |
mutationMap.delete(container); | |
}; | |
return {observe, unobserve}; | |
})(); | |
util.VideoItemObserver = VideoItemObserver; | |
const ItemDataConverter = { | |
makeSortText: text => { | |
return textUtil.convertKansuEi(text) | |
.replace(/([0-9]{1,9})/g, m => m.padStart(10, '0')).replace(/([0-9]{1,9})/g, m => m.padStart(10, '0')); | |
}, | |
fromFlapiMylistItem: (data) => { | |
const isChannel = data.id.startsWith('so'); | |
const isMymemory = /^[0-9]+$/.test(data.id); | |
const thumbnail = | |
data.is_middle_thumbnail ? | |
`${data.thumbnail_url}.M` : data.thumbnail_url; | |
return { | |
watchId: data.id, | |
videoId: data.id, | |
title: data.title, | |
duration: data.length_seconds * 1, | |
commentCount: data.num_res * 1, | |
mylistCount: data.mylist_counter * 1, | |
viewCount: data.view_counter * 1, | |
thumbnail, | |
postedAt: new Date(data.first_retrieve.replace(/-/g, '/')).toISOString(), | |
createdAt: new Date(data.create_time * 1000).toISOString(), | |
updatedAt: new Date(data.thread_update_time.replace(/-/g, '/')).toISOString(), | |
isChannel, | |
isMymemory, | |
mylistComment: data.mylist_comment || '', | |
_sortTitle: ItemDataConverter.makeSortText(data.title), | |
}; | |
}, | |
fromDeflistItem: (item) => { | |
const data = item.item_data; | |
const isChannel = data.video_id.startsWith('so'); | |
const isMymemory = !isChannel && /^[0-9]+$/.test(data.watch_id); | |
return { | |
watchId: isChannel ? data.video_id : data.watch_id, | |
videoId: data.video_id, | |
title: data.title, | |
duration: data.length_seconds * 1, | |
commentCount: data.num_res * 1, | |
mylistCount: data.mylist_counter * 1, | |
viewCount: data.view_counter * 1, | |
thumbnail: data.thumbnail_url, | |
postedAt: new Date(data.first_retrieve * 1000).toISOString(), | |
createdAt: new Date(item.create_time * 1000).toISOString(), | |
updatedAt: new Date(data.update_time * 1000).toISOString(), | |
isChannel, | |
isMymemory, | |
mylistComment: item.description || '', | |
_sortTitle: ItemDataConverter.makeSortText(data.title), | |
}; | |
}, | |
fromUploadedVideo: data => { | |
const isChannel = data.id.startsWith('so'); | |
const isMymemory = /^[0-9]+$/.test(data.id); | |
const thumbnail = | |
data.is_middle_thumbnail ? | |
`${data.thumbnail_url}.M` : data.thumbnail_url; | |
const [min, sec] = data.length.split(':'); | |
const postedAt = new Date(data.first_retrieve.replace(/-/g, '/')).toISOString(); | |
return { | |
watchId: data.id, | |
videoId: data.id, | |
title: data.title, | |
duration: min * 60 + sec * 1, | |
commentCount: data.num_res * 1, | |
mylistCount: data.mylist_counter * 1, | |
viewCount: data.view_counter * 1, | |
thumbnail, | |
postedAt, | |
createdAt: postedAt, | |
updatedAt: postedAt, | |
_sortTitle: ItemDataConverter.makeSortText(data.title), | |
}; | |
}, | |
fromSearchApiV2: data => { | |
const isChannel = data.id.startsWith('so'); | |
const isMymemory = /^[0-9]+$/.test(data.id); | |
const thumbnail = | |
data.is_middle_thumbnail ? | |
`${data.thumbnail_url}.M` : data.thumbnail_url; | |
const postedAt = new Date(data.first_retrieve.replace(/-/g, '/')).toISOString(); | |
return { | |
watchId: data.id, | |
videoId: data.id, | |
title: data.title, | |
duration: data.length_seconds, | |
commentCount: data.num_res * 1, | |
mylistCount: data.mylist_counter * 1, | |
viewCount: data.view_counter * 1, | |
thumbnail, | |
postedAt, | |
createdAt: postedAt, | |
updatedAt: postedAt, | |
isChannel, | |
isMymemory, | |
_sortTitle: ItemDataConverter.makeSortText(data.title), | |
}; | |
} | |
}; | |
class NicoQuery { | |
static parse(query) { | |
if (query instanceof NicoQuery) { | |
return query; | |
} | |
const [type, vars] = query.split('/'); | |
let [id, p] = (vars || '').split('?'); | |
id = decodeURIComponent(id || ''); | |
const params = textUtil.parseQuery(p || ''); | |
Object.keys(params).forEach(key => { | |
try { params[key] = JSON.parse(params[key]);} catch(e) {} | |
}); | |
return { | |
type, | |
id, | |
params | |
}; | |
} | |
static build(type, id, params) { | |
const p = Object.keys(params) | |
.sort() | |
.filter(key => !!params[key] && key !== 'title') | |
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(params[key]))}`); | |
if (params.title) { | |
p.push(`title=${encodeURIComponent(params.title)}`); | |
} | |
return `${type}/${encodeURIComponent(id)}?${p.join('&')}`; | |
} | |
static async fetch(query) { | |
if (typeof query === 'string') { | |
query = new NicoQuery(query); | |
} | |
const {type, id, params} = query; | |
const _req = { | |
query: query.toString(), | |
type, | |
id, | |
params, | |
}; | |
let result; | |
switch (type) { | |
case 'mylist': | |
_req.url = `https://flapi.nicovideo.jp/api/watch/mylistvideo?id=${id}`; | |
break; | |
case 'user': | |
_req.url = `https://flapi.nicovideo.jp/api/watch/uploadedvideo?user_id=${id}`; | |
break; | |
case 'mymylist': | |
_req.url = `https://www.nicovideo.jp/api/mylist/list?group_id=${id}`; | |
break; | |
case 'deflist': | |
_req.url = 'https://www.nicovideo.jp/api/deflist/list'; | |
break; | |
case 'nicorepo': | |
_req.url = 'https://www.nicovideo.jp/api/nicorepo/timeline/my/all?attribute_filter=upload&object_filter=video&client_app=pc_myrepo'; | |
break; | |
case 'mylistlist': | |
return Object.assign( | |
await MylistApiLoader.getMylistList(), | |
{_req} | |
); | |
case 'series': | |
return Object.assign( | |
await RecommendAPILoader.loadSeries(id, {}), | |
{_req} | |
); | |
case 'ranking': | |
return Object.assign( | |
await NicoRssLoader.loadRanking({genre: id || 'all'}), | |
{_req} | |
); | |
case 'channel': | |
_req.url = `https://ch.nicovideo.jp/${id}/video?rss=2.0`; | |
return Object.assign( | |
await NicoRssLoader.load(_req.url), | |
{_req} | |
); | |
case 'tag': | |
case 'search': | |
return Object.assign( | |
await NicoSearchApiV2Loader.searchMore(id, query.searchParams), | |
{_req} | |
); | |
default: | |
throw new Error('unknown query: ' + query); | |
} | |
result = await netUtil.fetch(_req.url, {credentials: 'include'}) | |
.then(res => res.json()); | |
return Object.assign(result, {_req}); | |
} | |
constructor(arg) { | |
if (typeof arg === 'string') { | |
arg = NicoQuery.parse(arg); | |
} | |
const {type, id, params} = arg; | |
this.type = type; | |
this.id = id || ''; | |
this.params = Object.assign({}, params || {}); | |
} | |
toString() { | |
return NicoQuery.build(this.type, this.id, this.params); | |
} | |
get title() { | |
if(this.params.title) { | |
return this.params.title; | |
} | |
const {type, id} = this; | |
switch(type) { | |
case 'tag': | |
return `タグ検索 「${this.searchWord}」`; | |
case 'search': | |
return `キーワード検索 「${this.searchWord}」`; | |
case 'user': | |
return `投稿動画一覧 user/${id}`; | |
case 'deflist': | |
return 'とりあえずマイリスト'; | |
case 'nicorepo': | |
return 'ニコレポ新着動画'; | |
case 'mylist': | |
case 'mymylist': | |
return `マイリスト mylist/${id}`; | |
case 'series': | |
return `シリーズ series/${id}`; | |
case 'ranking': | |
return `ランキング ranking/${id || 'all'}`; | |
case '': | |
return `チャンネル動画 channel/${id}`; | |
default: | |
return ''; | |
} | |
} | |
set title(v) { | |
this.params.title = v; | |
} | |
get baseString() { | |
return NicoQuery.build(this.type, this.id, this.baseParams); | |
} | |
get string() { | |
return this.toString(); | |
} | |
get baseParams() { | |
const params = Object.assign({}, this.params); | |
delete params.title; | |
return params; | |
} | |
get isSearch() { | |
return this.type === 'search' || this.type === 'tag'; | |
} | |
get isSearchReady() { | |
return this.isSearch && this.searchWord; | |
} | |
get searchWord() { | |
return (this.id || '').trim(); | |
} | |
get isOwnerFilterEnable() { | |
return this.params.ownerFilter || this.params.userId || this.params.chanelId; | |
} | |
set isOwnerFilterEnable(v) { | |
this.params.userId = this.params.chanelId = null; | |
if (v) { | |
this.params.ownerFilter = true; | |
} else { | |
this.params.ownerFilter = false; | |
} | |
} | |
get searchParams() { | |
const {type, params} = this; | |
return { | |
searchType: type, | |
order: (params.sort || '').charAt(0) === '-' ? 'd' : 'a', | |
sort: (params.sort || '').substring(1), | |
userId: this.isOwnerFilterEnable && params.userId || null, | |
channelId: this.isOwnerFilterEnable && params.channelId || null, | |
dateFrom: params.start || null, | |
dateTo: params.end || null, | |
commentCount: params.commentCount || null, | |
f_range: params.fRange || null, | |
l_range: params.lRange || null | |
}; | |
} | |
nearlyEquals(query) { | |
if (typeof query === 'string') { | |
query = new NicoQuery(query); | |
} | |
return this.baseString === query.baseString; | |
} | |
equals(query) { | |
if (typeof query === 'string') { | |
query = new NicoQuery(query); | |
} | |
return this.toString() === query.toString(); | |
} | |
} | |
util.NicoQuery = NicoQuery; | |
const sleep = Object.assign(function(time = 0) { | |
return new Promise(res => setTimeout(res, time)); | |
},{ | |
idle: (() => { | |
if (window.requestIdleCallback) { | |
return () => new Promise(res => requestIdleCallback(res)); | |
} | |
return () => new Promise(res => setTimeout(res, 0)); | |
}), | |
raf: () => new Promise(res => requestAnimationFrame(res)), | |
promise: () => Promise.resolve(), | |
resolve: Promise.resolve() | |
}); | |
util.sleep = sleep; | |
// already required | |
util.bounce = bounce; | |
ZenzaWatch.lib.$ = uQuery; | |
workerUtil.env({netUtil, global}); | |
const initCssProps = (win) => { | |
win = win || window; | |
const LEN = '<length>'; | |
const TM = '<time>'; | |
const LP = '<length-percentage>'; | |
const CL = '<color>'; | |
const NUM = '<number>'; | |
const SEC1 = cssUtil.s(1); | |
const PX0 = cssUtil.px(0); | |
const TP = 'transparent'; | |
const inherits = true; | |
cssUtil.registerProps( | |
{name: '--zenza-ui-scale', window: win, | |
syntax: NUM, initialValue: cssUtil.number(1), inherits}, | |
{name: '--zenza-control-bar-height', window: win, | |
syntax: LEN, initialValue: cssUtil.px(48), inherits}, | |
{name: '--zenza-comment-layer-opacity', window: win, | |
syntax: NUM, initialValue: cssUtil.number(1), inherits}, | |
{name: '--zenza-comment-panel-header-height', window: win, | |
syntax: LEN, initialValue: cssUtil.px(64), inherits}, | |
{name: '--sideView-left-margin', window: win, | |
syntax: LP, initialValue: cssUtil.px(CONSTANT.SIDE_PLAYER_WIDTH + 24), inherits}, | |
{name: '--sideView-top-margin', window: win, | |
syntax: LP, initialValue: cssUtil.px(76), inherits}, | |
{name: '--base-bg-color', window: win, | |
syntax: CL, initialValue: TP, inherits}, | |
{name: '--base-fore-color', window: win, | |
syntax: CL, initialValue: TP, inherits}, | |
{name: '--light-text-color', window: win, | |
syntax: CL, initialValue: TP, inherits}, | |
{name: '--scrollbar-bg-color', window: win, | |
syntax: CL, initialValue: TP, inherits}, | |
{name: '--scrollbar-thumb-color', window: win, | |
syntax: CL, initialValue: TP, inherits}, | |
{name: '--item-border-color', window: win, | |
syntax: CL, initialValue: TP, inherits}, | |
{name: '--hatsune-color', window: win, | |
syntax: CL, initialValue: TP, inherits}, | |
{name: '--enabled-button-color', window: win, | |
syntax: CL, initialValue: TP, inherits} | |
); | |
cssUtil.setProps( | |
[document.documentElement, '--inner-width', cssUtil.number(global.innerWidth)], | |
[document.documentElement, '--inner-height', cssUtil.number(global.innerHeight)] | |
); | |
}; | |
initCssProps(); | |
WindowResizeObserver.subscribe(({width, height}) => { | |
global.innerWidth = width; | |
global.innerHeight = height; | |
cssUtil.setProps( | |
[document.documentElement, '--inner-width', cssUtil.number(width)], | |
[document.documentElement, '--inner-height', cssUtil.number(height)] | |
); | |
}); | |
class BaseCommandElement extends HTMLElement { | |
static toAttributeName(camel) { | |
return 'data-' + camel.replace(/([A-Z])/g, s => '-' + s.toLowerCase()); | |
} | |
static toPropName(snake) { | |
return snake.replace(/^data-/, '').replace(/(-.)/g, s => s.charAt(1).toUpperCase()); | |
} | |
static async importLit() { | |
if (dll.lit) { | |
return dll.lit; | |
} | |
dll.lit = await util.dimport('https://unpkg.com/lit-html?module'); | |
return dll.lit; | |
} | |
static get observedAttributes() { | |
return []; | |
} | |
static get propTypes() { | |
return {}; | |
} | |
static get defaultProps() { | |
return {}; | |
} | |
static get defaultState() { | |
return {}; | |
} | |
static async getTemplate(state = {}, props = {}, events = {}) { | |
const {html} = dll.lit || await this.importLit(); | |
return html`<div id="root" data-state="${JSON.stringify(state)}" | |
data-props="${JSON.stringify(props)}" @click=${events.onClick}></div>`; | |
} | |
constructor() { | |
super(); | |
this._isConnected = false; | |
this.props = Object.assign({}, this.constructor.defaultProps, this._initialProps); | |
this.state = Object.assign({}, this.constructor.defaultState); | |
this._boundOnUIEvent = this.onUIEvent.bind(this); | |
this._boundOnCommand = this.onCommand.bind(this); | |
this.events = { | |
onClick: this._boundOnUIEvent | |
}; | |
this._idleRenderCallback = async () => { | |
this._idleCallbackId = null; | |
return await this.render(); | |
}; | |
} | |
get _initialProps() { | |
const props = {}; | |
for (const key of Object.keys(this.constructor.propTypes)) { | |
if (!this.dataset[key]) { continue; } | |
const type = typeof this.constructor.propTypes[key]; | |
props[key] = type !== 'string' ? JSON.parse(this.dataset[key]) : this.dataset[key]; | |
} | |
return props; | |
} | |
async render() { | |
if (!this._isConnected) { | |
return; | |
} | |
const {render} = dll.lit || await this.constructor.importLit(); | |
if (!this._shadow) { | |
this._shadow = this.attachShadow({mode: 'open'}); | |
} | |
render(await this.constructor.getTemplate(this.state, this.props, this.events), this._shadow); | |
if (!this._root) { | |
const root = this._shadow.querySelector('#root'); | |
if (!root) { | |
return; | |
} | |
this._root = root; | |
this._root.addEventListener('command', this._boundOnCommand); | |
} | |
} | |
requestRender(isImmediate = false) { | |
if (this._idleCallbackId) { | |
clearTimeout(this._idleCallbackId); | |
} | |
if (isImmediate) { | |
this._idleRenderCallback(); | |
} else { | |
this._idleCallbackId = setTimeout(this._idleRenderCallback, 0); | |
} | |
} | |
async connectedCallback() { | |
this._isConnected = true; | |
await this.render(); | |
} | |
async disconnectedCallback() { | |
this._isConnected = false; | |
if (this._root) { | |
this._root.removeEventListener('click', this._boundOnUIEvent); | |
this._root.removeEventListener('command', this._boundOnCommand); | |
this._root = null; | |
} | |
const {render} = dll.lit || await this.constructor.importLit(); | |
render('', this._shadow); | |
this._shadow = null; | |
} | |
attributeChangedCallback(attr, oldValue, newValue) { | |
attr = attr.startsWith('data-') ? this.constructor.toPropName(attr) : attr; | |
const type = typeof this.constructor.propTypes[attr]; | |
if (type !== 'string') { | |
newValue = JSON.parse(newValue); | |
} | |
if (this.props[attr] === newValue) { | |
return; | |
} | |
this.props[attr] = newValue; | |
this.requestRender(); | |
} | |
setProp(prop, value) { | |
this.setAttribute(prop, value); | |
} | |
setState(key, value) { | |
if (this._setState(key, value)) { | |
this.requestRender(); | |
return true; | |
} | |
return false; | |
} | |
_setState(key, value) { | |
if (typeof key !== 'string') { return this._setStates(key); } | |
if (!this.state.hasOwnProperty(key)) { return false; } | |
if (this.state[key] === value) { return false; } | |
this.state[key] = value; | |
return true; | |
} | |
_setStates(states) { | |
return Object.keys(states).filter(key => this._setState(key, states[key])).length > 0; | |
} | |
onUIEvent(e) { | |
const target = e.target.closest('[data-command]'); | |
if (!target) { | |
return; | |
} | |
let {command, param, type} = target.dataset; | |
if (['number', 'boolean', 'json'].includes(type)) { | |
param = JSON.parse(param); | |
} | |
e.preventDefault(); | |
e.stopPropagation(); | |
return this.dispatchCommand(command, param, e, target); | |
} | |
dispatchCommand(command, param, originalEvent = null, target = null) { | |
(target || this).dispatchEvent(new CustomEvent('command', {detail: {command, param, originalEvent}, bubbles: true, composed: true})); | |
} | |
onCommand(e) { | |
} | |
get propset() { | |
return Object.assign({}, this.props); | |
} | |
set propset(props) { | |
const keys = Object.keys(props).filter(key => this.props.hasOwnProperty(key)); | |
const changed = keys.filter(key => { | |
if (this.props[key] === props[key]) { | |
return false; | |
} | |
this.props[key] = props[key]; | |
return true; | |
}).length > 0; | |
if (changed) { | |
this.requestRender(); | |
} | |
} | |
} | |
const {VideoItemElement, VideoItemProps} = (() => { | |
const ITEM_HEIGHT = 100; | |
const THUMBNAIL_WIDTH = 96; | |
const THUMBNAIL_HEIGHT = 72; | |
const BLANK_THUMBNAIL = 'https://nicovideo.cdn.nimg.jp/web/img/series/no_thumbnail.png'; | |
const VideoItemProps = { | |
watchId: '', | |
videoId: '', | |
threadId: '', | |
title: '', | |
duration: 0, | |
commentCount: 0, | |
mylistCount: 0, | |
viewCount: 0, | |
thumbnail: BLANK_THUMBNAIL, | |
postedAt: '', | |
description: '', | |
mylistComment: '', | |
isChannel: false, | |
isMymemory: false, | |
ownerId: 0, | |
ownerName: '', | |
thumbInfo: {}, | |
hasInview: false, | |
lazyload: false | |
}; | |
const VideoItemAttributes = Object.keys(VideoItemProps).map(prop => BaseCommandElement.toAttributeName(prop)); | |
class VideoItemElement extends BaseCommandElement { | |
static get propTypes() { | |
return VideoItemProps; | |
} | |
static get defaultProps() { | |
return VideoItemProps; | |
} | |
static get observedAttributes() { | |
return VideoItemAttributes; | |
} | |
static get defaultState() { | |
return { | |
isActive: false, | |
isPlayed: false | |
}; | |
} | |
static async getTemplate(state = {}, props = {}, events = {}) { | |
const {html} = dll.list || await this.importLit(); | |
const watchId = props.watchId; | |
const watchUrl = `https://www.nicovideo.jp/watch/${props.watchId}`; | |
const title = props.title ? html`<span title="${props.title}">${props.title}<span>` : props.watchId; | |
const duration = props.duration ? html`<span class="duration">${textUtil.secToTime(props.duration)}</span>` : ''; | |
const postedAt = props.postedAt ? `${textUtil.dateToString(new Date(props.postedAt))}` : ''; | |
const thumbnail = props.lazyload ? BLANK_THUMBNAIL : props.thumbnail; | |
const counter = (props.viewCount || props.commentCount || props.mylistCount) ? html` | |
<div class="counter"> | |
<span class="count">再生: <span class="value viewCount">${props.viewCount}</span></span> | |
<span class="count">コメ: <span class="value commentCount">${props.commentCount}</span></span> | |
<span class="count">マイ: <span class="value mylistCount">${props.mylistCount}</span></span> | |
</div> | |
` : ''; | |
const classes = []; | |
props.isChannel && classes.push('is-channel'); | |
return html` | |
<div id="root" @click=${events.onClick} class="${classes.join(' ')}"> | |
<style> | |
* { | |
box-sizing: border-box; | |
} | |
#root { | |
background-color: var(--list-bg-color, #666); | |
box-sizing: border-box; | |
user-select: none; | |
content-visibility: auto; | |
} | |
.videoItem { | |
position: relative; | |
display: grid; | |
width: 100%; | |
height: ${ITEM_HEIGHT}px; | |
overflow: hidden; | |
grid-template-columns: ${THUMBNAIL_WIDTH}px 1fr; | |
grid-template-rows: ${THUMBNAIL_HEIGHT}px 1fr; | |
padding: 2px; | |
contain: layout size; | |
} | |
.thumbnailContainer { | |
position: relative; | |
/*transform: translate(0, 2px);*/ | |
margin: 0; | |
background-color: black; | |
background-size: contain; | |
background-repeat: no-repeat; | |
background-position: center; | |
} | |
.thumbnail { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 96px; | |
height: 72px; | |
object-fit: contain; | |
} | |
.thumbnailContainer a { | |
display: inline-block; | |
width: 96px; | |
height: 72px; | |
transition: box-shaow 0.4s ease, transform 0.4s ease; | |
} | |
.thumbnailContainer a:active { | |
box-shadow: 0 0 8px #f99; | |
transform: translate(0, 4px); | |
transition: none; | |
} | |
.thumbnailContainer .playlistAppend, | |
.playlistRemove, | |
.thumbnailContainer .deflistAdd, | |
.thumbnailContainer .pocket-info { | |
position: absolute; | |
display: none; | |
color: #fff; | |
background: #666; | |
width: 24px; | |
height: 20px; | |
line-height: 18px; | |
font-size: 14px; | |
box-sizing: border-box; | |
text-align: center; | |
font-weight: bolder; | |
cursor: pointer; | |
} | |
.thumbnailContainer .playlistAppend { | |
left: 0; | |
bottom: 0; | |
} | |
.playlistRemove { | |
right: 8px; | |
top: 0; | |
} | |
.thumbnailContainer .deflistAdd { | |
right: 0; | |
bottom: 0; | |
} | |
.thumbnailContainer .pocket-info { | |
display: none !important; | |
right: 24px; | |
bottom: 0; | |
} | |
:host-context(.is-pocketReady) .videoItem:hover .pocket-info { | |
display: inline-block !important; | |
} | |
.videoItem:hover .thumbnailContainer .playlistAppend, | |
.videoItem:hover .thumbnailContainer .deflistAdd, | |
.videoItem:hover .thumbnailContainer .pocket-info { | |
display: inline-block; | |
border: 1px outset; | |
} | |
.videoItem:hover .thumbnailContainer .playlistAppend:hover, | |
.videoItem:hover .thumbnailContainer .deflistAdd:hover, | |
.videoItem:hover .thumbnailContainer .pocket-info:hover { | |
transform: scale(1.5); | |
box-shadow: 2px 2px 2px #000; | |
} | |
.videoItem:hover .thumbnailContainer .playlistAppend:active, | |
.videoItem:hover .thumbnailContainer .deflistAdd:active, | |
.videoItem:hover .thumbnailContainer .pocket-info:active { | |
transform: scale(1.3); | |
border: 1px inset; | |
transition: none; | |
} | |
.videoItem.is-updating .thumbnailContainer .deflistAdd { | |
transform: scale(1.0) !important; | |
border: 1px inset !important; | |
pointer-events: none; | |
} | |
.thumbnailContainer .duration { | |
position: absolute; | |
right: 0; | |
bottom: 0; | |
background: #000; | |
font-size: 12px; | |
color: #fff; | |
} | |
.videoItem:hover .thumbnailContainer .duration { | |
display: none; | |
} | |
.videoInfo { | |
height: 100%; | |
padding-left: 4px; | |
} | |
.postedAt { | |
font-size: 12px; | |
color: var(--list-text-color, #ccc); | |
} | |
.is-played .postedAt::after { | |
content: ' ●'; | |
font-size: 10px; | |
} | |
.counter { | |
position: absolute; | |
top: 80px; | |
width: 100%; | |
text-align: center; | |
} | |
.title { | |
line-height: 20px; | |
height: 40px; | |
overflow: hidden; | |
} | |
.is-channel .title::before { | |
content: '[CH]'; | |
display: inline; | |
font-size: 12px; | |
background: #888; | |
color: #ccc; | |
padding: 0 2px; | |
margin: 0; | |
} | |
.videoLink { | |
font-size: 14px; | |
color: var(--list-video-link-color, #ff9); | |
transition: background 0.4s ease, color 0.4s ease; | |
} | |
.videoLink:visited { | |
color: var(--list-video-link-visited-color, #ffd); | |
} | |
.videoLink:active { | |
color: var(--list-video-link-active-color, #fff); | |
transition: none; | |
} | |
.noVideoCounter .counter { | |
display: none; | |
} | |
.counter { | |
font-size: 12px; | |
color: var(--list-text-color, #ccc); | |
} | |
.counter .value { | |
font-weight: bolder; | |
} | |
.counter .count { | |
white-space: nowrap; | |
} | |
.counter .count + .count { | |
margin-left: 8px; | |
} | |
</style> | |
<div class="videoItem"> | |
<span class="playlistRemove" data-command="playlistRemove" title="プレイリストから削除">×</span> | |
<div class="thumbnailContainer"> | |
<a class="command" data-command="open" data-param="${watchId}" href="${watchUrl}"> | |
<img src="${thumbnail}" class="thumbnail" loading="lazy"> | |
${duration} | |
</a> | |
<span class="playlistAppend" data-command="playlistAppend" data-param="${watchId}" title="プレイリストに追加">▶</span> | |
<span class="deflistAdd" data-command="deflistAdd" data-param="${watchId}" title="とりあえずマイリスト">✚</span> | |
<span class="pocket-info" data-command="pocket-info" data-param="${watchId}" title="動画情報">?</span> | |
</div> | |
<div class="videoInfo"> | |
<div class="postedAt">${postedAt}</div> | |
<div class="title"> | |
<a class="videoLink" data-command="open" data-param="${watchId}" href="${watchUrl}">${title}</a> | |
</div> | |
</div> | |
${counter} | |
</div> | |
</div>`; | |
} | |
_applyThumbInfo(thumbInfo) { | |
const data = thumbInfo.data || thumbInfo; // legacy 互換のため | |
const thumbnail = this.props.thumbnail.match(/smile\?i=/) ? | |
this.props.thumbnail : data.thumbnail; | |
const isChannel = data.v.startsWith('so') || data.owner.type === 'channel'; | |
const watchId = isChannel ? data.id : data.v; | |
Object.assign(this.dataset, { | |
watchId, | |
videoId: data.id, | |
title: data.title, | |
duration: data.duration, | |
commentCount: data.commentCount, | |
mylistCount: data.mylistCount, | |
viewCount: data.viewCount, | |
thumbnail, | |
postedAt: data.postedAt, | |
ownerId: data.owner.id, | |
ownerName: data.owner.name, | |
ownerIcon: data.owner.icon, | |
owerUrl: data.owner.url, | |
isChannel | |
}); | |
this.dispatchEvent(new CustomEvent('thumb-info', {detail: {props: this.props}, bubbles: true, composed: true})); | |
} | |
attributeChangedCallback(attr, oldValue, newValue) { | |
if (attr === 'data-lazyload') { | |
this.props.lazyload = newValue !== 'false'; | |
return this.requestRender(true); | |
} | |
if (attr !== 'data-thumb-info') { | |
return super.attributeChangedCallback(attr, oldValue, newValue); | |
} | |
const info = JSON.parse(newValue); | |
if (!info || info.status !== 'ok') { | |
return; | |
} | |
this._applyThumbInfo(info); | |
} | |
} | |
return {VideoItemElement, VideoItemProps}; | |
})(); | |
const {VideoSeriesProps, VideoSeriesLabel} = (() => { | |
const ITEM_HEIGHT = 100; | |
const THUMBNAIL_WIDTH = 120; | |
const DEFAULT_THUMBNAIL = 'https://nicovideo.cdn.nimg.jp/web/img/series/no_thumbnail.png'; | |
const VideoSeriesProps = { | |
id: 0, | |
title: '', | |
thumbnailUrl: DEFAULT_THUMBNAIL, | |
createdAt: '', | |
updatedAt: '' | |
}; | |
const VideoSeriesAttributes = Object.keys(VideoSeriesProps).map(prop => BaseCommandElement.toAttributeName(prop)); | |
class VideoSeriesLabel extends BaseCommandElement { | |
static get propTypes() { | |
return VideoSeriesProps; | |
} | |
static get defaultProps() { | |
return VideoSeriesProps; | |
} | |
static get observedAttributes() { | |
return VideoSeriesAttributes; | |
} | |
static async getTemplate(state = {}, props = {}, events = {}) { | |
const {html} = dll.list || await this.importLit(); | |
if (!props.id) { | |
return html``; | |
} | |
const title = props.title || `series/${props.id}`; | |
const url = `https://www.nicovideo.jp/series/${props.id}`; | |
const thumbnail = props.thumbnailUrl? props.thumbnailUrl : DEFAULT_THUMBNAIL; | |
const updatedAt = textUtil.dateToString(props.updatedAt); | |
return html` | |
<div id="root" @click=${events.onClick}> | |
<style> | |
* { | |
box-sizing: border-box; | |
} | |
#root { | |
box-sizing: border-box; | |
user-select: none; | |
cursor: pointer; | |
color: #ccc; | |
} | |
.seriesInfo { | |
position: relative; | |
display: grid; | |
width: 100%; | |
height: ${ITEM_HEIGHT}px; | |
overflow: hidden; | |
grid-template-columns: ${THUMBNAIL_WIDTH}px 1fr; | |
contain: layout size; | |
padding: 8px; | |
border: 1px; | |
transition: transform 0.2s, box-shadow 0.2s; | |
background-color: #666; | |
border-radius: 4px; | |
} | |
#root .seriesInfo { | |
transform: translate(0, -4px); | |
box-shadow: 0 4px 0 #333; | |
} | |
#root:active .seriesInfo { | |
transition: none; | |
transform: none; | |
box-shadow: none; | |
color: #fff; | |
text-shadow: 0 0 6px #fff; | |
} | |
.thumbnailContainer { | |
position: relative; | |
background-color: black; | |
height: ${ITEM_HEIGHT - 16}px; | |
} | |
.thumbnail { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: ${THUMBNAIL_WIDTH}px; | |
height: ${ITEM_HEIGHT - 16}px; | |
object-fit: cover; | |
filter: sepia(100%); | |
} | |
#root:hover .thumbnail { | |
filter: none; | |
} | |
.info { | |
height: 100%; | |
padding: 4px 8px; | |
display: flex; | |
} | |
.info p { | |
font-size: 12px; | |
margin: 0; | |
} | |
.title { | |
line-height: 20px; | |
overflow: hidden; | |
word-break: break-all; | |
} | |
.seriesLink { | |
font-size: 14px; | |
color: var(--list-video-link-color, #ff9); | |
transition: background 0.4s ease, color 0.4s ease; | |
} | |
.seriesLink:hover { | |
text-decoration: underline; | |
} | |
.seriesLink:visited { | |
color: var(--list-video-link-visited-color, #ffd); | |
} | |
.seriesLink:active { | |
color: var(--list-video-link-active-color, #fff); | |
transition: none; | |
} | |
.playButton { | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%) scale(1.5); | |
width: 32px; | |
height: 24px; | |
border-radius: 8px; | |
text-align: center; | |
background: rgba(0, 0, 0, 0.8); | |
box-shadow: 0 0 4px #ccc; | |
transition: transform 0.2s ease, box-shadow 0.2s, text-shadow 0.2s, font-size 0.2s; | |
font-size: 22px; | |
line-height: 25px; | |
} | |
#root:hover .playButton { | |
transform: translate(-50%, -50%) scale(2.0); | |
} | |
#root:active .playButton { | |
transform: translate(-50%, -50%) scale(3.0, 1.2); | |
} | |
</style> | |
<div class="seriesInfo" data-command="playlistSetSeries" data-param="${props.id}" title="このシリーズを見る"> | |
<div class="thumbnailContainer"> | |
<img src="${thumbnail}" class="thumbnail" loading="lazy"> | |
<div class="playButton">▶</div> | |
</div> | |
<div class="info"> | |
<div class="title"> | |
<p>動画シリーズ</p> | |
<a class="seriesLink" href="${url}" data-command="open-window" data-param="${url}">${title}</a> | |
<p clas="updatedAt">${updatedAt}</p> | |
</div> | |
</div> | |
</div> | |
</div>`; | |
} | |
onCommand(e) { | |
if (e.detail.command === 'open-window') { | |
window.open(e.detail.param); | |
} | |
} | |
} | |
if (window.customElements) { | |
customElements.get('zenza-video-series-label') || window.customElements.define('zenza-video-series-label', VideoSeriesLabel); | |
} | |
return { | |
VideoSeriesProps, VideoSeriesLabel | |
}; | |
})(); | |
if (window.customElements && !customElements.get('no-web-component')) { | |
window.customElements.define('no-web-component', class extends HTMLElement { | |
constructor() { | |
super(); | |
this.hidden = true; | |
this.attachShadow({mode: 'open'}); | |
} | |
}); | |
} | |
class RangeBarElement extends HTMLElement { | |
getTemplate() { | |
return uq.html` | |
<div id="root"> | |
<style> | |
* { | |
box-sizing: border-box; | |
user-select: none; | |
--back-color: #333; | |
--fore-color: #ccc; | |
--width: 64px; | |
--height: 8px; | |
--range-percent: 0%; | |
} | |
#root { | |
width: var(--width); | |
height: 100%; | |
display: flex; | |
align-items: center; | |
} | |
input, .meter { | |
width: var(--width); | |
height: var(--height); | |
} | |
input { | |
-webkit-appearance: none; | |
pointer-events: auto; | |
opacity: 0; | |
outline: none; | |
cursor: pointer; | |
} | |
input::-webkit-slider-thumb { | |
-webkit-appearance: none; | |
height: var(--height); | |
width: 2px; | |
} | |
input::-moz-range-thumb { | |
height: var(--height); | |
width: 2px; | |
} | |
.meter { | |
position: absolute; | |
display: inline-block; | |
vertical-align: middle; | |
background-color: var(--back-color) !important; | |
contain: style layout size; | |
pointer-events: none; | |
} | |
.tooltip { | |
display: none; | |
pointer-events: none; | |
position: absolute; | |
left: 50%; | |
top: -24px; | |
transform: translateX(-50%); | |
font-size: 12px; | |
line-height: 16px; | |
padding: 2px 4px; | |
border: 1px solid #000; | |
background: #ffc; | |
color: black; | |
text-shadow: none; | |
white-space: nowrap; | |
z-index: 100; | |
} | |
.tooltip:empty { display: none !mportant; } | |
#root:active .tooltip { display: inline-block; } | |
</style> | |
<div class="meter" style="background: | |
linear-gradient(to right, | |
var(--fore-color), var(--fore-color) var(--range-percent), | |
var(--back-color) 0, var(--back-color) | |
) !important;"><div class="tooltip"></div></div> | |
</div>`; | |
} | |
constructor() { | |
super(); | |
this.update = throttle.raf(this.update.bind(this)); | |
this.onChange = this.onChange.bind(this); | |
this.onKey = e => e.preventDefault(); | |
this.onFocus = e => { | |
console.warn('focus'); | |
e.target.blur(); | |
}; | |
this._value = this.getAttribute('value') || ''; | |
} | |
connectedCallback() { | |
if (this._rangeInput) { | |
return; | |
} | |
const range = this.querySelector('input[type=range]'); | |
if (range) { | |
this.rangeInput = range; | |
} | |
} | |
onChange() { | |
this.update(); | |
domEvent.dispatchCustomEvent(this, 'input', {value: this.value}, {bubbles: true, composed: true}); | |
} | |
update() { | |
if (!this.rangeInput) { return; } | |
this.rangeInput.blur(); | |
const range = this.rangeInput; | |
const min = range.min * 1; | |
const max = range.max * 1; | |
const value = range.value * 1; | |
if (this.lastValue === value) { | |
return; | |
} | |
this.lastValue = value; | |
const per = value / Math.abs(max - min) * 100; | |
this.meter.style.setProperty('--range-percent', cssUtil.percent(per)); | |
this.tooltip.textContent = `${Math.round(per)}%`; | |
} | |
initShadow() { | |
if (this.shadowRoot) { | |
return; | |
} | |
this.attachShadow({mode: 'open'}); | |
const $tpl = this.$tpl = this.getTemplate(); | |
$tpl.appendTo(this.shadowRoot); | |
this.meter = $tpl.find('.meter')[0]; | |
this.tooltip = $tpl.find('.tooltip')[0]; | |
} | |
get rangeInput() { | |
return this._rangeInput; | |
} | |
set rangeInput(range) { | |
this._rangeInput = range; | |
range.view = this; | |
this._value && (range.value = this._value); | |
this.initShadow(); | |
this.meter.after(range); | |
this.update(); | |
uq(range).on('input', this.onChange); | |
} | |
get value() { | |
return this.rangeInput ? this.rangeInput.value : this._value; | |
} | |
set value(v) { | |
this._value = v; | |
if (this.rangeInput) { | |
this.rangeInput.value = v; | |
this.update(); | |
} | |
} | |
} | |
cssUtil.registerProps( | |
{name: '--range-percent', syntax: '<percentage>', initialValue: cssUtil.percent(0), inherits: true} | |
); | |
if (window.customElements) { | |
customElements.get('zenza-range-bar') || window.customElements.define('zenza-range-bar', RangeBarElement); | |
} | |
const {DialogElement, DialogProps} = (() => { | |
const DialogProps = {}; | |
const DialogAttributes = Object.keys(DialogProps).map(prop => BaseCommandElement.toAttributeName(prop)); | |
class DialogElement extends BaseCommandElement { | |
static get propTypes() { | |
return DialogProps; | |
} | |
static get defaultProps() { | |
return DialogProps; | |
} | |
static get observedAttributes() { | |
return DialogAttributes; | |
} | |
static get defaultState() { | |
return { | |
isOpen: false | |
}; | |
} | |
static async getContentsTemplate(state = {}, props = {}, events = {}) { | |
return null; | |
} | |
static async getTemplate(state = {}, props = {}, events = {}) { | |
const {html} = dll.list || await this.importLit(); | |
const body = html` | |
<style> | |
* { | |
box-sizing: border-box; | |
overscroll-behavior: none; | |
} | |
*::-webkit-scrollbar { | |
background: transparent; | |
/*bordedr-radius: 6px;*/ | |
width: 16px; | |
} | |
*::-webkit-scrollbar-thumb { | |
/*border-radius: 4px;*/ | |
background: var(--scrollbar-thumb-color, #999); | |
box-shadow: 0 0 4px #333 inset; | |
will-change: transform; | |
} | |
*::-webkit-scrollbar-button { | |
display: none; | |
} | |
#root { | |
--dialog-border-width: 12px; | |
--dialog-background-color: rgba(48, 48, 48, 0.9); | |
--dialog-text-color: #ccc; | |
text-align: left; | |
} | |
button { | |
cursor: pointer; | |
outline: none; | |
} | |
.dialog { | |
position: fixed; | |
top: 50%; | |
left: 50%; | |
width: var(--dialog-width, 60vw); | |
height: var(--dialog-height, 80vh); | |
z-index: 1000000; | |
will-change: transform; | |
visibility: hidden; | |
user-select: none; | |
transform: | |
translate(-50%, -50%); | |
border-radius: 16px; | |
transform-origin: center top; | |
animation-name: closing; | |
animation-fill-mode: forwards; | |
animation-iteration-count: 1; | |
animation-duration: 0.5s; | |
animation-timing-function: linear; | |
border: 1px solid rgba(128, 128, 128, 0.5); | |
} | |
.dialog.is-open::before { | |
content: ''; | |
position: fixed; | |
top: calc(-50vh + 50%); | |
left: calc(-50vw + 50%); | |
width: 100vw; | |
height: 100vh; | |
animation-name: opening-shadow; | |
visibility: hidden; | |
animation-delay: 1s; | |
animation-fill-mode: forwards; | |
animation-iteration-count: 1; | |
animation-duration: 1s; | |
animation-timing-function: linear; | |
} | |
@keyframes opening-shadow { | |
0% { visibility: hidden; } | |
100% { visibility: visible; } | |
} | |
.dialog.is-open { | |
visibility: visible; | |
animation-name: opening; | |
} | |
@keyframes closing { | |
0% { | |
visibility: visible; | |
overflow: hidden; | |
transform: | |
translate(-50%, -50%); | |
} | |
10% { | |
visibility: visible; | |
transform: | |
translate(-50%, -50%) | |
skew(-20deg) | |
translate(20vw, 0); | |
} | |
45% { | |
visibility: hidden; | |
transform: | |
translate(-50%, -50%) | |
skew(-20deg) | |
translate(100vw, 0); | |
} | |
100% {} | |
} | |
@keyframes opening { | |
0% { | |
visibility: visible; | |
overflow: hidden; | |
transform: | |
translate(-50%, -50%) | |
skew(20deg) | |
translate(200vw, 0); | |
} | |
90% { | |
transform: | |
translate(-50%, -50%) | |
skew(20deg) | |
; | |
} | |
93% { | |
transform: | |
translate(-50%, -50%) | |
skew(-10deg); | |
} | |
95% { | |
transform: | |
translate(-50%, -50%) | |
skew(5deg); | |
} | |
100% { | |
overflow: visible; | |
transform: | |
translate(-50%, -50%); | |
} | |
} | |
.dialog-background { | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
border-radius: 16px; | |
border-width: 16px; | |
border-style: solid; | |
--hue: calc(var(--current-hue, 0) + 120); | |
--hsl: hsla(var(--hue, 0), 50%, 80%, 0.5); | |
color: var(--hsl, hsla(120, 50%, 30%, 0.8)); | |
box-shadow: | |
0 0 8px hsl(var(--hue, 0), 50%, 30%), | |
0 0 4px hsl(var(--hue, 0), 50%, 50%); | |
border-color: currentcolor; | |
} | |
.dialog.is-open .dialog-background { | |
animation-name: hue-roll; | |
animation-delay: 1s; | |
animation-fill-mode: forwards; | |
animation-iteration-count: infinite; | |
animation-duration: 30s; | |
animation-timing-function: linear; | |
} | |
@keyframes hue-roll { | |
0% {--current-hue: 0;} | |
100% {--current-hue: 360;} | |
} | |
.dialog-inner { | |
position: absolute; | |
background: hsla(var(--hue, 120), 10%, 15%, 0.9); | |
opacity: 1; | |
z-index: 100; | |
top: -4px; | |
left: -4px; | |
bottom: -4px; | |
right: -4px; | |
padding-right: 16px; | |
color: var(--dialog-text-color, #ccc); | |
overflow: auto; | |
overscroll-behavior: none; | |
border-radius: 8px; | |
border: 12px solid transparent; | |
box-shadow: 0 0 0 1px hsla(var(--hue, 0), 50%, 10%, 0.5); | |
} | |
h1, h2, h3, h4, h5, h6, h7, summary { | |
background: rgba(3, 147, 147, 1); | |
/*box-shadow: 0 0 8px rgba(0, 0, 0, 0.5) inset;*/ | |
text-shadow: 1px 1px #999; | |
border-radius: 4px; | |
font-weight: bold; | |
color: #333; | |
padding: 4px 8px; | |
margin: 0 8px 0 0; | |
text-align: left; | |
} | |
h3 { | |
margin: 0 auto; | |
width: calc(100% - 16px); | |
background: rgba(192, 192, 192, 0.8); | |
box-shadow: none; | |
} | |
summary { | |
margin: 0 0 16px; | |
cursor: pointer; | |
outline: none; | |
font-size: 150%; | |
} | |
summary::-webkit-details-marker { | |
color: #f39393; | |
text-shadow: 0 0 1px red; | |
} | |
details: { | |
margin: 0 0 16px; | |
} | |
p { | |
padding: 8px; | |
margin: 0; | |
} | |
button, input[type=button] { | |
cursor: pointer; | |
} | |
textarea, input, select, option { | |
background: transparent; | |
color: var(--dialog-text-color, #ccc); | |
} | |
</style> | |
<div class="dialog-background" data-command="nop"> | |
<div class="dialog-inner">${this.getContentsTemplate(html, state, props, events)}</div> | |
</div> | |
` ; | |
return html` | |
<div id="root" @click=${events.onClick}> | |
<div class="dialog ${state.isOpen ? 'is-open' : ''}" | |
data-command="close"> | |
<form @change=${events.onChange} @keydown=${events.onKeyDown} @keyup=${events.onKeyUp}> | |
${state.isOpen ? body : ''} | |
</form> | |
</div> | |
</div> | |
`; | |
} | |
constructor() { | |
super(); | |
const onKey = this.onKey.bind(this); | |
Object.assign(this.events, { | |
onChange: this.onChange.bind(this), | |
onKeyDown: onKey, | |
onKeyUp: onKey | |
}); | |
Object.assign(this.state, this.props); | |
cssUtil.registerProps({ | |
name: '--current-hue', | |
syntax: '<number>', | |
initialValue: 0, | |
inherits: true | |
}); | |
} | |
async connectedCallback() { | |
await super.connectedCallback(); | |
if (!this._root) { return; } | |
this._dialog = this._root.querySelector('.dialog'); | |
this._dialog.addEventListener('animationend', e => { | |
if (e.animationName !== 'opening') { return; } | |
if (this.state.isOpen) { | |
this.onOpen(); | |
} | |
}); | |
} | |
get isOpen() { | |
return this.state.isOpen; | |
} | |
set isOpen(v) { | |
if (this.isOpen === v) { return; } | |
!!v ? this.open() : this.close(); | |
} | |
get dialog() { | |
return this._dialog; | |
} | |
open() { | |
this.setState({isOpen: true}); | |
this._dialog && this._dialog.classList.add('is-open'); | |
} | |
close() { | |
this._dialog && this._dialog.classList.remove('is-open'); | |
setTimeout(() => this.setState({isOpen: false}), 1000); | |
} | |
toggle() { | |
this.isOpen ? this.close() : this.open(); | |
} | |
onCommand(e) { | |
const {command, param} = e.detail; | |
switch (command) { | |
case 'close': | |
this.close(); | |
break; | |
default: | |
return; | |
} | |
e.stopPropagation(); | |
e.preventDefault(); | |
} | |
onChange(e) { | |
} | |
onOpen() { | |
} | |
onKey(e) { | |
const target = (e.path && e.path[0]) ? e.path[0] : e.target; | |
if (target.tagName === 'SELECT' || | |
target.tagName === 'INPUT' || | |
target.tagName === 'TEXTAREA') { | |
e.stopPropagation(); | |
} | |
} | |
attributeChangedCallback(attr, oldValue, newValue) { | |
} | |
} | |
return {DialogElement, DialogProps}; | |
})(); | |
const {SettingPanelElement} = (() => { | |
class SettingPanelElement extends DialogElement { | |
static get defaultState() { | |
return { | |
isOpen: false, | |
revision: 0 | |
}; | |
} | |
static getPlayerSettingMenu(html, conf) { | |
return html` | |
<details class="player-setting"> | |
<summary>プレイヤーの設定</summary> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="autoPlay" | |
?checked=${conf.autoPlay}> | |
自動で再生する | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="enableTogglePlayOnClick" | |
?checked=${conf.enableTogglePlayOnClick}> | |
画面クリックで再生/一時停止 | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="autoFullScreen" | |
?checked=${conf.autoFullScreen}> | |
自動でフルスクリーンにする | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="enableSingleton" | |
?checked=${conf.enableSingleton}> | |
ZenzaWatchを起動してるタブがあればそちらで開く<br> | |
<smal>(singletonモード)</small> | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="enableHeatMap" | |
?checked=${conf.enableHeatMap}> | |
コメントの盛り上がりをシークバーに表示 | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="overrideGinza" | |
?checked=${conf.overrideGinza}> | |
動画視聴ページでも公式プレイヤーの代わりに起動する | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="overrideWatchLink" | |
?checked=${conf.overrideWatchLink}> | |
[Zen]ボタンなしでZenzaWatchを開く(リロード後に反映) | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="enableStoryboard" | |
?checked=${conf.enableStoryboard}> | |
シークバーにサムネイルを表示 <small>(※ プレミアム)</small> | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="uaa.enable" | |
?checked=${conf['uaa.enable']}> | |
ニコニ広告の情報を取得する(対応ブラウザのみ) | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="enableAutoMylistComment" | |
?checked=${conf.enableAutoMylistComment}> | |
マイリストコメントに投稿者名を入れる | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="autoDisableDmc" | |
?checked=${conf.autoDisableDmc}> | |
旧システムのほうが画質が良さそうな時は旧システムを使う<br> | |
<small>たまに誤爆することがあります (回転情報の含まれる動画など)</small> | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="enableNicosJumpVideo" | |
?checked=${conf.enableNicosJumpVideo} | |
data-command="toggle-enableNicosJumpVideo"> | |
@ジャンプで指定された動画をプレイリストに入れる | |
</label> | |
</div> | |
<div class="enableOnlyRequired control toggle"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="video.hls.enableOnlyRequired" | |
?checked=${conf.video.hls.enableOnlyRequired} | |
data-command="toggle-video.hls.enableOnlyRequired"> | |
HLSが必須の動画だけHLSを使用する (※ HLSが重い環境用) | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" data-setting-name="touch.enable" | |
data-command="toggle-touchEnable" | |
?checked=${conf.touch.enable}> | |
タッチパネルのジェスチャを有効にする | |
<smal>(2本指左右シーク・上下で速度変更/3本指で動画切替)</small> | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="bestZenTube" | |
?checked=${conf.bestZenTube} | |
data-command="toggle-bestZenTube"> | |
ZenTube使用時に最高画質をリクエストする (※ 機能してないかも) | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="loadLinkedChannelVideo" | |
?checked=${conf.loadLinkedChannelVideo}> | |
無料期間の切れた動画はdアニメの映像を流す<br> | |
<small>(当然ながらdアニメニコニコチャンネル加入が必要)</small> | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<select class="menuScale" data-setting-name="menuScale" data-type="number"> | |
<option value="0.8" ?selected=${conf.menuScale == 0.8}>0.8倍</option> | |
<option value="1" ?selected=${conf.menuScale == 1}>標準</option> | |
<option value="1.2" ?selected=${conf.menuScale == 1.2}>1.2倍</option> | |
<option value="1.5" ?selected=${conf.menuScale == 1.5}>1.5倍</option> | |
<option value="2.0" ?selected=${conf.menuScale == 2}>2倍</option> | |
</select> | |
ボタンの大きさ(倍率) | |
<small>※ 一部レイアウトが崩れます</small> | |
</label> | |
</div> | |
</div> | |
`; | |
} | |
static getCommentSettingMenu(html, conf) { | |
return html` | |
<details class="comment-setting"> | |
<summary>コメント・フォントの設定</summary> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="autoCommentSpeedRate" | |
?checked=${conf.autoCommentSpeedRate}> | |
倍速再生でもコメントは速くしない | |
<small>※ コメントのレイアウトが一部崩れます</small> | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="backComment" | |
?checked=${conf.backComment}> | |
コメントを動画の後ろに流す | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="baseFontBolder" | |
?checked=${conf.baseFontBolder}> | |
フォントを太くする | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<select class="commentSpeedRate" | |
data-setting-name="commentSpeedRate" data-type="number"> | |
<option value="0.5" ?selected=${conf.commentSpeedRate==0.5}>0.5倍</option> | |
<option value="0.8" ?selected=${conf.commentSpeedRate==0.8}>0.8倍</option> | |
<option value="1" ?selected=${conf.commentSpeedRate==1}>標準</option> | |
<option value="1.2" ?selected=${conf.commentSpeedRate==1.2}>1.2倍</option> | |
<option value="1.5" ?selected=${conf.commentSpeedRate==1.5}>1.5倍</option> | |
<option value="2.0" ?selected=${conf.commentSpeedRate==2.0}>2倍</option> | |
</select> | |
コメントの速度(倍率) | |
<small>※ コメントのレイアウトが一部崩れます</small> | |
</label> | |
</div> | |
<div class="control"> | |
<h3>フォント名</h3> | |
<label> | |
<span class="info">入力例: 「'游ゴシック', 'メイリオ', '戦国TURB'」</span> | |
<input type="text" class="textInput" value=${conf.baseFontFamily} | |
data-setting-name="baseFontFamily"> | |
</label> | |
</div> | |
<div class="control"> | |
<h3>投稿者コメントの影の色</h3> | |
<label> | |
<span class="info">※ リロード後に反映</span> | |
<input type="text" class="textInput" | |
pattern="(#[0-9A-Fa-f]{3}|#[0-9A-Fa-f]{6}|^[a-zA-Z]+$)" | |
data-setting-name="commentLayer.ownerCommentShadowColor" | |
value=${conf.commentLayer.ownerCommentShadowColor} | |
> | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
フォントサイズ(倍率) | |
<input type="number" value=${conf.baseChatScale} | |
min="0.5" max="2.0" step="0.1" | |
data-setting-name="baseChatScale" data-type="number" | |
> | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
コメントの透明度 | |
<input type="range" value=${conf.commentLayerOpacity} | |
min="0.1" max="1.0" step="0.1" | |
data-setting-name="commentLayerOpacity" data-type="number" | |
> | |
</label> | |
<label> | |
かんたんコメント | |
<input type="range" value=${conf.commentLayer.easyCommentOpacity} | |
min="0.1" max="1.0" step="0.1" | |
data-setting-name="commentLayer.easyCommentOpacity" data-type="number" | |
> | |
</label> | |
</div> | |
<div class="control"> | |
<h3>コメントの影</h3> | |
<label> | |
<input type="radio" | |
name="textShadowType" | |
data-setting-name="commentLayer.textShadowType" | |
?checked=${conf.commentLayer.textShadowType==''} | |
value=""> | |
標準 (軽い) | |
</label> | |
<label> | |
<input type="radio" | |
name="textShadowType" | |
data-setting-name="commentLayer.textShadowType" | |
?checked=${conf.commentLayer.textShadowType=='shadow-type2'} | |
value="shadow-type2"> | |
縁取り | |
</label> | |
<label> | |
<input type="radio" | |
name="textShadowType" | |
data-setting-name="commentLayer.textShadowType" | |
?checked=${conf.commentLayer.textShadowType=='shadow-type3'} | |
value="shadow-type3"> | |
ぼかし (重い) | |
</label> | |
<label> | |
<input type="radio" | |
name="textShadowType" | |
data-setting-name="commentLayer.textShadowType" | |
?checked=${conf.commentLayer.textShadowType=='shadow-stroke'} | |
value="shadow-stroke"> | |
縁取り2 (対応ブラウザのみ。やや重い) | |
</label> | |
<label style="font-family: 'dokaben_ver2_1' !important;"> | |
<input type="radio" | |
name="textShadowType" | |
data-setting-name="commentLayer.textShadowType" | |
?checked=${conf.commentLayer.textShadowType=='shadow-dokaben'} | |
value="shadow-dokaben"> | |
ドカベン <s>(飽きたら消します)</s> | |
</label> | |
</div> | |
</details> | |
`; | |
} | |
static getFilterSettingMenu(html, conf) { | |
const word = Array.isArray(conf.wordFilter) ? conf.wordFilter .join('\n') : conf.wordFilter; | |
const command = Array.isArray(conf.commandFilter) ? conf.commandFilter.join('\n') : conf.commandFilter; | |
const userId = Array.isArray(conf.userIdFilter) ? conf.userIdFilter .join('\n') : conf.userIdFilter; | |
return html` | |
<style> | |
.filterEdit { | |
display: block; | |
width: 100%; | |
min-height: 100px; | |
margin: 0 auto 0; | |
color: currentcolor; | |
} | |
</style> | |
<details class="filter-setting"> | |
<summary>NG・フィルタ設定</summary> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="enableFilter" | |
?checked=${conf.enableFilter}> | |
NGを有効にする | |
</label> | |
</div> | |
<div class="control"> | |
<label> | |
<input type="checkbox" class="checkbox" | |
data-setting-name="removeNgMatchedUser" | |
?checked=${conf.removeNgMatchedUser}> | |
コメントがNGにマッチしたら、その発言者のコメントを全て消す | |
</label> | |
</div> | |
<div class="control" style="text-align: center;"> | |
<h3>NG共有</h3> | |
<label class="short"> | |
<input type="radio" | |
name="sharedNgLevel" | |
data-setting-name="sharedNgLevel" | |
?checked=${conf.sharedNgLevel=='NONE'} | |
value="NONE"> | |
OFF | |
</label> | |
<label class="short"> | |
<input type="radio" | |
name="sharedNgLevel" | |
data-setting-name="sharedNgLevel" | |
?checked=${conf.sharedNgLevel=='LOW'} | |
value="LOW"> | |
弱 | |
</label> | |
<label class="short"> | |
<input type="radio" | |
name="sharedNgLevel" | |
data-setting-name="sharedNgLevel" | |
?checked=${conf.sharedNgLevel=='MID'} | |
value="MID"> | |
中 | |
</label> | |
<label class="short"> | |
<input type="radio" | |
name="sharedNgLevel" | |
data-setting-name="sharedNgLevel" | |
?checked=${conf.sharedNgLevel=='HIGH'} | |
value="HIGH"> | |
強 | |
</label> | |
<label class="short"> | |
<input type="radio" | |
name="sharedNgLevel" | |
data-setting-name="sharedNgLevel" | |
?checked=${conf.sharedNgLevel=='MAX'} | |
value="MAX"> | |
MAX | |
</label> | |
</div> | |
<div class="control" style="text-align: center;"> | |
<h3>表示するコメント</h3> | |
<label class="short"> | |
<input type="checkbox" | |
data-setting-name="filter.fork0" | |
?checked=${conf.filter.fork0} | |
value=""> | |
通常コメント | |
</label> | |
<label class="short"> | |
<input type="checkbox" | |
data-setting-name="filter.fork1" | |
?checked=${conf.filter.fork1} | |
value=""> | |
投稿者コメント | |
</label> | |
<label class="short"> | |
<input type="checkbox" | |
data-setting-name="filter.fork2" | |
?checked=${conf.filter.fork2} | |
value=""> | |
かんたんコメント | |
</label> | |
</div> | |
<div class="control"> | |
<h3>NGワード</h3> | |
<label> | |
<textarea | |
class="filterEdit" | |
data-setting-name="wordFilter" data-type="array" | |
>${word}</textarea> | |
</label> | |
<h3>NGコマンド</h3> | |
<label> | |
<textarea | |
class="filterEdit" | |
data-setting-name="commandFilter" data-type="array" | |
>${command}</textarea> | |
</label> | |
<h3>NGユーザー</h3> | |
<label> | |
<textarea | |
class="filterEdit" | |
data-setting-name="userIdFilter" data-type="array" | |
>${userId}</textarea> | |
</label> | |
</div> | |
</details> | |
`; | |
} | |
static getContentsTemplate(html, state = {}, props = {}, events = {}) { | |
const conf = props.config.props; | |
return html` | |
<style> | |
label { | |
display: block; | |
margin: 8px; | |
padding: 8px; | |
cursor: pointer; | |
} | |
label.short { | |
display: inline-block; | |
min-width: 15%; | |
} | |
label:hover { | |
border-radius: 4px; | |
background: rgba(80, 80, 80, 0.3); | |
} | |
input[type=checkbox], input[type=radio] { | |
transform: scale(2); | |
margin-right: 8px; | |
} | |
input[type=text], input[type=number], select { | |
border-radius: 4px; | |
border: 1px solid currentcolor; | |
font-size: 150%; | |
padding: 8px; | |
background: transparent; | |
color: currentcolor; | |
} | |
input[type=range] { | |
width: 70%; | |
margin: auto; | |
cursor: pointer; | |
border-radius: 4px; | |
border: 1px solid currentcolor; | |
} | |
.import-export { | |
padding: 8px; | |
text-align: center; | |
outline: none; | |
} | |
.export-config-button { | |
display: inline-block; | |
} | |
.import-config-file-select { | |
position: absolute; | |
text-indent: -9999px; | |
width: 160px; | |
padding: 8px; | |
opacity: 0; | |
cursor: pointer; | |
} | |
.import-config-file-select-label { | |
pointer-events: none; | |
user-select: none; | |
} | |
.import-config-file-select-label, | |
.export-config-button { | |
display: inline-block; | |
width: 160px; | |
padding: 8px; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 14px; | |
color: #000; | |
background: #ccc; | |
border: 0; | |
} | |
</style> | |
<div data-revision="${state.revision}"> | |
${this.getPlayerSettingMenu(html, conf)} | |
${this.getCommentSettingMenu(html, conf)} | |
${this.getFilterSettingMenu(html, conf)} | |
<details> | |
<summary>設定のインポート・エクスポート</summary> | |
<div class="import-export"> | |
<button class="export-config-button" data-command="export-config">ファイルに保存</button> | |
<input type="file" | |
@change=${events.onImportFileSelect} | |
class="import-config-file-select" | |
accept=".json" | |
data-command="nop"> | |
<div class="import-config-file-select-label">ファイルから読み込む</div> | |
</div> | |
</details> | |
</div> | |
`; | |
} | |
constructor() { | |
super(); | |
Object.assign(this.events, { | |
onChange: this.onChange.bind(this), | |
onImportFileSelect: this.onImportFileSelect.bind(this) | |
}); | |
} | |
get config() { | |
return this.props.config; | |
} | |
set config(v) { | |
this.props.config = v; | |
this.state.revision++; | |
} | |
onUIEvent(e) { | |
if (e.target.closest('label, input, select, textarea') || e.target.tagName === 'SUMMARY') { | |
e.stopPropagation(); | |
return; | |
} | |
super.onUIEvent(e); | |
} | |
onChange(e) { | |
const elm = ((e.path && e.path[0]) ? e.path[0] : e.target) || {}; | |
const {settingName, type} = elm.dataset || {}; | |
if (!settingName) { | |
return super.onChange(e); | |
} | |
let value = elm.value; | |
if (elm.tagName === 'INPUT' && elm.type === 'checkbox') { | |
value = elm.checked; | |
} else { | |
if (['number', 'boolean', 'json'].includes(type)) { | |
value = JSON.parse(value); | |
} else if (type === 'array') { | |
value = value.split('\n'); | |
} | |
} | |
this.config.props[settingName] = value; | |
e.stopPropagation(); | |
} | |
onOpen() { | |
super.onOpen(); | |
this.state.revision++; | |
} | |
async onImportFileSelect(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
const file = e.target.files[0]; | |
if (!/\.config\.json$/.test(file.name)) { | |
return; | |
} | |
if (!confirm(`ファイル "${file.name}" で書き換えますか?`)) { | |
return; | |
} | |
domEvent.dispatchCommand(e.target, 'close'); | |
const fileReader = new FileReader(); | |
fileReader.onload = ev => { | |
this._playerConfig.importJson(ev.target.result); | |
location.reload(); | |
}; | |
fileReader.readAsText(file); | |
} | |
} | |
return {SettingPanelElement}; | |
})(); | |
const components = (() => { | |
if (self.customElements) { | |
customElements.get('zenza-video-item') || customElements.define('zenza-video-item', VideoItemElement); | |
customElements.get('zenza-dialog') || customElements.define('zenza-dialog', DialogElement); | |
customElements.get('zenza-setting-panel') || customElements.define('zenza-setting-panel', SettingPanelElement); | |
} | |
return { | |
BaseCommandElement, | |
VideoItemElement, | |
VideoSeriesLabel, | |
RangeBarElement, | |
DialogElement, | |
SettingPanelElement | |
}; | |
})(); | |
class BaseState extends Emitter { | |
static getInstance() { | |
if (!this.instance) { | |
this.instance = new this.constructor(); | |
} | |
return this.instance; | |
} | |
static defineProps(self, props = {}) { | |
const def = {}; | |
Object.keys(props).sort() | |
.forEach(key => { | |
def[key] = { | |
enumerable: !key.startsWith('_'), | |
get() { return self._state[key]; }, | |
set(val) { self.setState(key, val); } | |
}; | |
}); | |
Object.defineProperties(self, def); | |
} | |
constructor(state) { | |
super(); | |
this._name = ''; | |
this._state = state; | |
this._changed = new Map; | |
this._timestamp = performance.now(); | |
this._boundOnChange = _.debounce(this._onChange.bind(this), 0); | |
this.constructor.defineProps(this, state); | |
} | |
_updateTimestamp() { | |
return this._timestamp = performance.now(); | |
} | |
onkey(key, func) {return this.on(`update-${key}`, func);} | |
offkey(key, func) {return this.off(`update-${key}`, func);} | |
_onChange() { | |
const changed = this._changed; | |
if (!changed.size) { | |
return; | |
} | |
this.emit('change', changed, changed.size); | |
for (const [key, val] of changed) { | |
this.emit('update', key, val); | |
this.emit(`update-${key}`, val); | |
} | |
this._changed.clear(); | |
} | |
setState(key, val) { | |
if (typeof key === 'string') { | |
return this._setState(key, val); | |
} | |
for (const [k, v] of (key instanceof Map ? key : Object.entries(key))) { | |
this._setState(k, v); | |
} | |
} | |
_setState(key, val) { | |
if (!this._state.hasOwnProperty(key)) { | |
console.warn('%cUnknown property %s = %s', 'background: yellow;', key, val); | |
} | |
if (this._state[key] === val) { | |
return; | |
} | |
this._state[key] = val; | |
this._changed.set(key, val); | |
this._boundOnChange(); | |
} | |
} | |
class PlayerState extends BaseState { | |
static getInstance(config) { | |
if (!PlayerState.instance) { | |
PlayerState.instance = new PlayerState(config); | |
} | |
return PlayerState.instance; | |
} | |
constructor(config) { | |
super({ | |
isAbort: false, | |
isBackComment: config.props.backComment, | |
isChanging: false, | |
isCanPlay: false, | |
isChannel: false, | |
isShowComment: config.props.showComment, | |
isCommentReady: false, | |
isCommentPosting: false, | |
isCommunity: false, | |
isWaybackMode: false, | |
isDebug: config.props.debug, | |
isDmcAvailable: false, | |
isDmcPlaying: false, | |
isError: false, | |
isEnded: false, | |
isLoading: false, | |
isLoop: config.props.loop, | |
isMute: config.props.mute, | |
isMymemory: false, | |
isLiked: false, | |
isOpen: false, | |
isPausing: true, | |
isPlaylistEnable: false, | |
isPlaying: false, | |
isSeeking: false, | |
isRegularUser: !nicoUtil.isPremium(), | |
isStalled: false, | |
isUpdatingDeflist: false, | |
isUpdatingMylist: false, | |
isNotPlayed: true, | |
isYouTube: false, | |
isEnableFilter: config.props.enableFilter, | |
sharedNgLevel: config.props.sharedNgLevel, | |
currentSrc: '', | |
currentTab: config.props.videoInfoPanelTab, | |
errorMessage: '', | |
screenMode: config.props.screenMode, | |
playbackRate: config.props.playbackRate, | |
thumbnail: '', | |
videoCount: {}, | |
videoSession: {} | |
}); | |
this.name = 'Player'; | |
} | |
set videoInfo(videoInfo) { | |
if (this._videoInfo) { | |
this._videoInfo.update(videoInfo); | |
} else { | |
this._videoInfo = videoInfo; | |
} | |
global.debug.videoInfo = videoInfo; | |
this.videoCount = videoInfo.count; | |
this.thumbnail = videoInfo.betterThumbnail; | |
this.emit('update-videoInfo', videoInfo); | |
} | |
get videoInfo() { | |
return this._videoInfo; | |
} | |
set chatList(chatList) { | |
this._chatList = chatList; | |
this.emit('update-chatList', this._chatList); | |
} | |
get chatList() { | |
return this._chatList; | |
} | |
resetVideoLoadingStatus() { | |
this.setState({ | |
isLoading: true, | |
isPlaying: false, | |
isPausing: true, | |
isCanPlay: false, | |
isSeeking: false, | |
isStalled: false, | |
isError: false, | |
isAbort: false, | |
isMymemory: false, | |
isCommunity: false, | |
isChannel: false, | |
isEnded: false, | |
currentSrc: CONSTANT.BLANK_VIDEO_URL | |
}); | |
} | |
setVideoCanPlay() { | |
this.setState({ | |
isStalled: false, isLoading: false, isPausing: true, isNotPlayed: true, isError: false, isSeeking: false, isCanPlay: true, isEnded: false | |
}); | |
} | |
setPlaying() { | |
this.setState({ | |
isPlaying: true, | |
isPausing: false, | |
isCanPlay: false, | |
isLoading: false, | |
isNotPlayed: false, | |
isError: false, | |
isStalled: false, | |
isEnded: false | |
}); | |
} | |
setPausing() { | |
this.setState({isPlaying: false, isPausing: true}); | |
} | |
setVideoEnded() { | |
this.setState({isPlaying: false, isPausing: true, isSeeking: false, isEnded: true}); | |
} | |
setVideoErrorOccurred() { | |
this.setState({isError: true, isPlaying: false, isPausing: true, isLoading: false, isSeeking: false}); | |
} | |
} | |
class VideoControlState extends BaseState { | |
constructor(state = {}) { | |
super(Object.assign({ | |
isSeeking: false, | |
isDragging: false, | |
isWheelSeeking: false, | |
isStoryboardAvailable: false | |
}, state)); | |
this.name = 'VideoControl'; | |
} | |
} | |
const CacheStorage = (() => { | |
const PREFIX = `${PRODUCT}_cache_`; | |
class CacheStorage { | |
constructor(storage) { | |
this._storage = storage; | |
this.gc = _.debounce(this.gc.bind(this), 100); | |
} | |
gc(now = NaN) { | |
const storage = this._storage; | |
now = isNaN(now) ? Date.now() : now; | |
Object.keys(storage).forEach(key => { | |
if (key.indexOf(PREFIX) === 0) { | |
let item; | |
try { | |
item = JSON.parse(this._storage[key]); | |
} catch(e) { | |
storage.removeItem(key); | |
} | |
if (item.expiredAt === '' || item.expiredAt > now) { | |
return; | |
} | |
storage.removeItem(key); | |
} | |
}); | |
} | |
setItem(key, data, expireTime) { | |
key = PREFIX + key; | |
const expiredAt = | |
typeof expireTime === 'number' ? (Date.now() + expireTime) : ''; | |
const cacheData = { | |
data: data, | |
type: typeof data, | |
expiredAt: expiredAt | |
}; | |
try { | |
this._storage[key] = JSON.stringify(cacheData); | |
this.gc(); | |
} catch (e) { | |
if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') { | |
this.gc(0); | |
} | |
} | |
} | |
getItem(key) { | |
key = PREFIX + key; | |
if (!(this._storage.hasOwnProperty(key) || this._storage[key] !== undefined)) { | |
return null; | |
} | |
let item = null; | |
try { | |
item = JSON.parse(this._storage[key]); | |
} catch(e) { | |
this._storage.removeItem(key); | |
return null; | |
} | |
if (item.expiredAt === '' || item.expiredAt > Date.now()) { | |
return item.data; | |
} | |
return null; | |
} | |
removeItem(key) { | |
key = PREFIX + key; | |
if (this._storage.hasOwnProperty(key) || this._storage[key] !== undefined) { | |
this._storage.removeItem(key); | |
} | |
} | |
clear() { | |
const storage = this._storage; | |
Object.keys(storage).forEach(v => { | |
if (v.indexOf(PREFIX) === 0) { | |
storage.removeItem(v); | |
} | |
}); | |
} | |
} | |
return CacheStorage; | |
})(); | |
const VideoInfoLoader = (function () { | |
const cacheStorage = new CacheStorage(sessionStorage); | |
const parseFromGinza = function (dom) { | |
try { | |
let watchApiData = JSON.parse(dom.querySelector('#watchAPIDataContainer').textContent); | |
let videoId = watchApiData.videoDetail.id; | |
let hasLargeThumbnail = nicoUtil.hasLargeThumbnail(videoId); | |
let flvInfo = textUtil.parseQuery( | |
decodeURIComponent(watchApiData.flashvars.flvInfo) | |
); | |
let dmcInfo = JSON.parse( | |
decodeURIComponent(watchApiData.flashvars.dmcInfo || '{}') | |
); | |
let thumbnail = | |
watchApiData.flashvars.thumbImage + | |
(hasLargeThumbnail ? '.L' : ''); | |
let videoUrl = flvInfo.url ? flvInfo.url : ''; | |
let isEco = /\d+\.\d+low$/.test(videoUrl); | |
let isFlv = /\/smile\?v=/.test(videoUrl); | |
let isMp4 = /\/smile\?m=/.test(videoUrl); | |
let isSwf = /\/smile\?s=/.test(videoUrl); | |
let isDmc = watchApiData.flashvars.isDmc === 1 && dmcInfo.movie.session; | |
let csrfToken = watchApiData.flashvars.csrfToken; | |
let playlistToken = watchApiData.playlistToken; | |
let watchAuthKey = watchApiData.flashvars.watchAuthKey; | |
let seekToken = watchApiData.flashvars.seek_token; | |
let threads = []; | |
let msgInfo = { | |
server: flvInfo.ms, | |
threadId: flvInfo.thread_id * 1, | |
duration: flvInfo.l, | |
userId: flvInfo.user_id, | |
isNeedKey: flvInfo.needs_key === '1', | |
optionalThreadId: flvInfo.optional_thread_id, | |
defaultThread: {id: flvInfo.thread_id * 1}, | |
optionalThreads: [], | |
layers: [], | |
threads, | |
userKey: flvInfo.userkey, | |
hasOwnerThread: !!watchApiData.videoDetail.has_owner_thread, | |
when: null | |
}; | |
if (msgInfo.hasOwnerThread) { | |
threads.push({ | |
id: flvInfo.thread_id * 1, | |
isThreadkeyRequired: flvInfo.needs_key === '1', | |
isDefaultPostTarget: false, | |
fork: 1, | |
isActive: true, | |
label: 'owner' | |
}); | |
} | |
threads.push({ | |
id: flvInfo.thread_id * 1, | |
isThreadkeyRequired: flvInfo.needs_key === '1', | |
isDefaultPostTarget: true, | |
isActive: true, | |
label: flvInfo.needs_key === '1' ? 'community' : 'default' | |
}); | |
let playlist = | |
JSON.parse(dom.querySelector('#playlistDataContainer').textContent); | |
const isPlayableSmile = isMp4 && !isSwf && (videoUrl.indexOf('http') === 0); | |
const isPlayable = isDmc || (isMp4 && !isSwf && (videoUrl.indexOf('http') === 0)); | |
cacheStorage.setItem('csrfToken', csrfToken, 30 * 60 * 1000); | |
dmcInfo.quality = { | |
audios: (dmcInfo.movie.session || {audios: []}).audios.map(id => {return {id, available: true, bitrate: 64000};}), | |
videos: (dmcInfo.movie.session || {videos: []}).videos.reverse() | |
.map((id, level_index) => { return { | |
id, | |
available: true, | |
level_index, | |
bitrate: parseInt(id.replace(/^.*_(\d+)kbps.*/, '$1')) * 1000 | |
};}) | |
.reverse() | |
}; | |
let result = { | |
_format: 'watchApi', | |
watchApiData, | |
flvInfo, | |
dmcInfo, | |
msgInfo, | |
playlist, | |
isDmcOnly: isPlayable && !isPlayableSmile, | |
isPlayable, | |
isMp4, | |
isFlv, | |
isSwf, | |
isEco, | |
isDmc, | |
thumbnail, | |
csrfToken, | |
playlistToken, | |
watchAuthKey, | |
seekToken | |
}; | |
emitter.emitAsync('csrfTokenUpdate', csrfToken); | |
return result; | |
} catch (e) { | |
window.console.error('error: parseFromGinza ', e); | |
return null; | |
} | |
}; | |
// ここだよ! | |
const parseFromHtml5Watch = function (dom) { | |
const watchDataContainer = dom.querySelector('#js-initial-watch-data'); | |
const data = JSON.parse(watchDataContainer.getAttribute('data-api-data')); | |
const env = JSON.parse(watchDataContainer.getAttribute('data-environment')); | |
const videoId = data.video.id; | |
const hasLargeThumbnail = nicoUtil.hasLargeThumbnail(videoId); | |
const flvInfo = data.video.smileInfo || {}; | |
const dmcInfo = data.media.delivery || {}; | |
const thumbnail = data.video.thumbnail.largeUrl; | |
const videoUrl = flvInfo.url ? flvInfo.url : ''; | |
const isEco = /\d+\.\d+low$/.test(videoUrl); | |
const isFlv = /\/smile\?v=/.test(videoUrl); | |
const isMp4 = /\/smile\?m=/.test(videoUrl); | |
const isSwf = /\/smile\?s=/.test(videoUrl); | |
const isDmc = !!dmcInfo && !!dmcInfo.movie.session; | |
window.console.log(isDmc); | |
window.console.log(dmcInfo); | |
const csrfToken = null; | |
const watchAuthKey = null; | |
const playlistToken = env.playlistToken; | |
const context = data.context; | |
const commentComposite = data.comment; | |
const threads = commentComposite.threads.map(t => Object.assign({}, t)); | |
const layers = commentComposite.layers.map(t => Object.assign({}, t)); | |
layers.forEach(layer => { | |
layer.threadIds.forEach(({id, fork}) => { | |
threads.forEach(thread => { | |
if (thread.id === id && fork === 0) { | |
thread.layer = layer; | |
} | |
}); | |
}); | |
}); | |
const linkedChannelVideo = false; | |
const isNeedPayment = false; | |
const defaultThread = threads.find(t => t.isDefaultPostTarget); | |
const msgInfo = { | |
server: commentComposite.server.url, | |
threadId: defaultThread ? defaultThread.id : (data.thread.ids.community || data.thread.ids.default), | |
duration: data.video.duration, | |
userId: data.viewer.id, | |
isNeedKey: threads.findIndex(t => t.isThreadkeyRequired) >= 0, // (isChannel || isCommunity) | |
optionalThreadId: '', | |
defaultThread, | |
optionalThreads: threads.filter(t => t.id !== defaultThread.id) || [], | |
threads, | |
userKey: data.comment.keys.userKey, | |
hasOwnerThread: threads.find(t => t.isOwnerThread), | |
when: null | |
}; | |
const isPlayableSmile = isMp4 && !isSwf && (videoUrl.indexOf('http') === 0); | |
const isPlayable = isDmc || (isMp4 && !isSwf && (videoUrl.indexOf('http') === 0)); | |
cacheStorage.setItem('csrfToken', csrfToken, 30 * 60 * 1000); | |
const playlist = {playlist: []}; | |
const tagList = []; | |
data.tag.items.forEach(t => { | |
tagList.push({ | |
_data: t, | |
id: t.id, | |
tag: t.name, | |
dic: t.isDictionaryExists, | |
lock: t.isLocked, // 形式が統一されてない悲しみを吸収 | |
owner_lock: t.isLocked ? 1 : 0, | |
lck: t.isLocked ? '1' : '0', | |
cat: t.isCategory | |
}); | |
}); | |
let channelInfo = null, channelId = null; | |
if (data.channel) { | |
channelInfo = { | |
icon_url: data.channel.iconURL || '', | |
id: data.channel.id, | |
name: data.channel.name, | |
is_favorited: data.channel.isFavorited ? 1 : 0 | |
}; | |
channelId = channelInfo.id; | |
} | |
let uploaderInfo = null; | |
if (data.owner) { | |
uploaderInfo = { | |
icon_url: data.owner.iconURL, | |
id: data.owner.id, | |
nickname: data.owner.nickname, | |
is_favorited: data.owner.isFavorited, | |
isMyVideoPublic: data.owner.isUserMyVideoPublic | |
}; | |
} | |
const watchApiData = { | |
videoDetail: { | |
v: data.client.watchId, | |
id: data.video.id, | |
title: data.video.title, | |
title_original: data.video.originalTitle, | |
description: data.video.description, | |
description_original: data.video.originalDescription, | |
postedAt: data.video.postedDateTime, | |
thumbnail: data.video.thumbnail.url, | |
largeThumbnail: data.video.thumbnail.player, | |
length: data.video.duration, | |
commons_tree_exists: !!data.video.isCommonsTreeExists, | |
width: data.video.width, | |
height: data.video.height, | |
isChannel: data.channel && data.channel.id, | |
isMymemory: false, | |
communityId: data.community ? data.community.id : null, | |
isPremiumOnly: data.viewer.isPremiumOnly, | |
isLiked: data.video.viewer.like.isLiked, | |
channelId, | |
commentCount: data.video.count.comment, | |
mylistCount: data.video.count.mylist, | |
viewCount: data.video.count.view, | |
tagList, | |
}, | |
viewerInfo: {id: data.viewer.id}, | |
channelInfo, | |
uploaderInfo | |
}; | |
let ngFilters = null; | |
if (data.video && data.video.dmcInfo && data.video.dmcInfo.thread && data.video.dmcInfo.thread) { | |
if (data.video.dmcInfo.thread.channel_ng_words && data.video.dmcInfo.thread.channel_ng_words.length) { | |
ngFilters = data.video.dmcInfo.thread.channel_ng_words; | |
} else if (data.video.dmcInfo.thread.owner_ng_words && data.video.dmcInfo.thread.owner_ng_words.length) { | |
ngFilters = data.video.dmcInfo.thread.owner_ng_words; | |
} | |
} | |
if (data.context && data.context.ownerNGList && data.context.ownerNGList.length) { | |
ngFilters = data.context.ownerNGList; | |
} | |
if (ngFilters && ngFilters.length) { | |
const ngtmp = []; | |
ngFilters.forEach(ng => { | |
if (!ng.source || !ng.destination) { return; } | |
ngtmp.push( | |
encodeURIComponent(ng.source) + '=' + encodeURIComponent(ng.destination)); | |
}); | |
flvInfo.ng_up = ngtmp.join('&'); | |
} | |
const result = { | |
_format: 'html5watchApi', | |
_data: data, | |
watchApiData, | |
flvInfo, | |
dmcInfo, | |
msgInfo, | |
playlist, | |
isDmcOnly: isPlayable && !isPlayableSmile, | |
isPlayable, | |
isMp4, | |
isFlv, | |
isSwf, | |
isEco, | |
isDmc, | |
thumbnail, | |
csrfToken, | |
watchAuthKey, | |
playlistToken, | |
series: data.series, | |
isNeedPayment, | |
linkedChannelVideo, | |
resumeInfo: { | |
initialPlaybackType: (data.player.initialPlayback? data.player.initialPlayback.type : ''), | |
initialPlaybackPosition: (data.player.initialPlayback? data.player.initialPlayback.positionSec : 0) | |
} | |
}; | |
emitter.emitAsync('csrfTokenUpdate', csrfToken); | |
return result; | |
}; | |
const parseWatchApiData = function (src) { | |
const dom = document.createElement('div'); | |
dom.innerHTML = src; | |
if (dom.querySelector('#watchAPIDataContainer')) { | |
return parseFromGinza(dom); | |
} else if (dom.querySelector('#js-initial-watch-data')) { | |
return parseFromHtml5Watch(dom); | |
} else if (dom.querySelector('#PAGEBODY .mb16p4 .font12')) { | |
return { | |
reject: true, | |
reason: 'forbidden', | |
message: dom.querySelector('#PAGEBODY .mb16p4 .font12').textContent, | |
}; | |
} else { | |
return null; | |
} | |
}; | |
const loadLinkedChannelVideoInfo = (originalData) => { | |
const linkedChannelVideo = originalData.linkedChannelVideo; | |
const originalVideoId = originalData.watchApiData.videoDetail.id; | |
const videoId = linkedChannelVideo.linkedVideoId; | |
originalData.linkedChannelData = null; | |
if (originalVideoId === videoId) { | |
return Promise.reject(); | |
} | |
const url = `https://www.nicovideo.jp/watch/${videoId}`; | |
window.console.info('%cloadLinkedChannelVideoInfo', 'background: cyan', linkedChannelVideo); | |
return new Promise(r => { | |
setTimeout(r, 1000); | |
}).then(() => netUtil.fetch(url, {credentials: 'include'})) | |
.then(res => res.text()) | |
.then(html => { | |
const dom = document.createElement('div'); | |
dom.innerHTML = html; | |
const data = parseFromHtml5Watch(dom); | |
originalData.dmcInfo = data.dmcInfo; | |
originalData.isDmcOnly = data.isDmcOnly; | |
originalData.isPlayable = data.isPlayable; | |
originalData.isMp4 = data.isMp4; | |
originalData.isFlv = data.isFlv; | |
originalData.isSwf = data.isSwf; | |
originalData.isEco = data.isEco; | |
originalData.isDmc = data.isDmc; | |
return originalData; | |
}) | |
.catch(() => { | |
return Promise.reject({reason: 'network', message: '通信エラー(loadLinkedChannelVideoInfo)'}); | |
}); | |
}; | |
const onLoadPromise = (watchId, options, isRetry, resp) => { | |
const data = parseWatchApiData(resp); | |
debug.watchApiData = data; | |
if (!data) { | |
return Promise.reject({ | |
reason: 'network', | |
message: '通信エラー。動画情報の取得に失敗しました。(watch api)' | |
}); | |
} | |
if (data.reject) { | |
return Promise.reject(data); | |
} | |
if (!data.isDmc && (data.isFlv && !data.isEco)) { | |
return Promise.reject({ | |
reason: 'flv', | |
info: data, | |
message: 'この動画はZenzaWatchで再生できません(flv)' | |
}); | |
} | |
if ( | |
!data.isPlayable && | |
data.isNeedPayment && | |
data.linkedChannelVideo && | |
Config.getValue('loadLinkedChannelVideo')) { | |
return loadLinkedChannelVideoInfo(data); | |
} | |
if (!data.isPlayable) { | |
return Promise.reject({ | |
reason: 'not supported', | |
info: data, | |
message: 'この動画はZenzaWatchで再生できません' | |
}); | |
} | |
emitter.emitAsync('loadVideoInfo', data, 'WATCH_API', watchId); | |
return Promise.resolve(data); | |
}; | |
const createSleep = function (sleepTime) { | |
return new Promise(resolve => setTimeout(resolve, sleepTime)); | |
}; | |
const loadPromise = function (watchId, options, isRetry = false) { | |
let url = `https://www.nicovideo.jp/watch/${watchId}`; | |
console.log('%cloadFromWatchApiData...', 'background: lightgreen;', watchId, url); | |
const query = []; | |
if (options.economy === true) { | |
query.push('eco=1'); | |
} | |
if (query.length > 0) { | |
url += '?' + query.join('&'); | |
} | |
return netUtil.fetch(url, {credentials: 'include'}) | |
.then(res => res.text()) | |
.catch(() => Promise.reject({reason: 'network', message: '通信エラー(network)'})) | |
.then(onLoadPromise.bind(this, watchId, options, isRetry)) | |
.catch(err => { | |
window.console.error('err', {err, isRetry, url, query}); | |
if (isRetry) { | |
return Promise.reject({ | |
watchId, | |
message: err.message || '動画情報の取得に失敗したか、未対応の形式です', | |
type: 'watchapi' | |
}); | |
} | |
if (err.reason === 'forbidden') { | |
return Promise.reject(err); | |
} else if (err.reason === 'network') { | |
return createSleep(5000).then(() => { | |
window.console.warn('network error & retry'); | |
return loadPromise(watchId, options, true); | |
}); | |
} else if (err.reason === 'flv' && !options.economy) { | |
options.economy = true; | |
window.console.log( | |
'%cエコノミーにフォールバック(flv)', | |
'background: cyan; color: red;'); | |
return createSleep(500).then(() => { | |
return loadPromise(watchId, options, true); | |
}); | |
} else { | |
window.console.info('watch api fail', err); | |
return Promise.reject({ | |
watchId, | |
message: err.message || '動画情報の取得に失敗', | |
info: err.info | |
}); | |
} | |
}); | |
}; | |
return { | |
load: function (watchId, options) { | |
const timeKey = `watchAPI:${watchId}`; | |
window.console.time(timeKey); | |
return loadPromise(watchId, options).then( | |
(result) => { | |
window.console.timeEnd(timeKey); | |
return result; | |
}, | |
(err) => { | |
err.watchId = watchId; | |
window.console.timeEnd(timeKey); | |
return Promise.reject(err); | |
} | |
); | |
} | |
}; | |
})(); | |
const ThumbInfoLoader = (() => { | |
const BASE_URL = 'https://ext.nicovideo.jp/'; | |
const MESSAGE_ORIGIN = 'https://ext.nicovideo.jp/'; | |
let gate = null; | |
const initGate = () => { | |
if (gate) { return gate; } | |
gate = new CrossDomainGate({ | |
baseUrl: BASE_URL, | |
origin: MESSAGE_ORIGIN, | |
type: 'thumbInfo' | |
}); | |
}; | |
const load = async watchId => { | |
initGate(); | |
const thumbInfo = await gate.fetch(`${BASE_URL}api/getthumbinfo/${watchId}`, | |
{_format: 'text', expireTime: 24 * 60 * 60 * 1000}) | |
.catch(e => { return {status: 'fail', message: e.message || `gate.fetch('${watchId}') failed` }; }); | |
if (thumbInfo.status !== 'ok') { | |
return Promise.reject(thumbInfo); | |
} | |
return thumbInfo; | |
}; | |
return {initGate, load}; | |
})(); | |
const MylistApiLoader = (() => { | |
const CACHE_EXPIRE_TIME = 5 * 60 * 1000; | |
const TOKEN_EXPIRE_TIME = 59 * 60 * 1000; | |
let cacheStorage = null; | |
let token = ''; | |
if (ZenzaWatch) { | |
emitter.on('csrfTokenUpdate', t => { | |
token = t; | |
if (cacheStorage) { | |
cacheStorage.setItem('csrfToken', token, TOKEN_EXPIRE_TIME); | |
} | |
}); | |
} | |
class MylistApiLoader { | |
constructor() { | |
if (!cacheStorage) { | |
cacheStorage = new CacheStorage(sessionStorage); | |
} | |
if (!token) { | |
token = cacheStorage.getItem('csrfToken'); | |
if (token) { | |
console.log('cached token exists', token); | |
} | |
} | |
} | |
setCsrfToken(t) { | |
token = t; | |
if (cacheStorage) { | |
cacheStorage.setItem('csrfToken', token, TOKEN_EXPIRE_TIME); | |
} | |
} | |
async getDeflistItems(options = {}) { | |
const url = 'https://www.nicovideo.jp/api/deflist/list'; | |
const cacheKey = 'deflistItems'; | |
const sortItem = this.sortItem; | |
let cacheData = cacheStorage.getItem(cacheKey); | |
if (cacheData) { | |
if (options.sort) { | |
cacheData = sortItem(cacheData, options.sort, 'www'); | |
} | |
return cacheData; | |
} | |
const result = await netUtil.fetch(url, {credentials: 'include'}).then(r => r.json()) | |
.catch(e => { throw new Error('とりあえずマイリストの取得失敗(2)', e); }); | |
if (result.status !== 'ok' || (!result.list && !result.mylistitem)) { | |
throw new Error('とりあえずマイリストの取得失敗(1)', result); | |
} | |
let data = result.list || result.mylistitem; | |
cacheStorage.setItem(cacheKey, data, CACHE_EXPIRE_TIME); | |
if (options.sort) { | |
data = sortItem(data, options.sort, 'www'); | |
} | |
return data; | |
} | |
async getMylistItems(groupId, options = {}) { | |
if (groupId === 'deflist') { | |
return this.getDeflistItems(options); | |
} | |
const url = `https://flapi.nicovideo.jp/api/watch/mylistvideo?id=${groupId}`; | |
const cacheKey = `mylistItems: ${groupId}`; | |
const sortItem = this.sortItem; | |
const cacheData = cacheStorage.getItem(cacheKey); | |
if (cacheData) { | |
return options.sort ? sortItem(cacheData, options.sort, 'flapi') : cacheData; | |
} | |
const result = await netUtil.fetch(url, {credentials: 'include'}) | |
.then(r => r.json()) | |
.catch(e => { throw new Error('マイリストの取得失敗(2)', e); }); | |
if (result.status !== 'ok' || (!result.list && !result.mylistitem)) { | |
throw new Error('マイリストの取得失敗(1)', result); | |
} | |
let data = result.list || result.mylistitem; | |
data.id = groupId; | |
cacheStorage.setItem(cacheKey, data, CACHE_EXPIRE_TIME); | |
if (options.sort) { | |
data = sortItem(data, options.sort, 'flapi'); | |
} | |
return data; | |
} | |
sortItem(items, sortId, format) { | |
sortId = parseInt(sortId, 10); | |
let sortKey = ([ | |
'create_time', 'create_time', | |
'mylist_comment', 'mylist_comment', // format = wwwの時はdescription | |
'title', 'title', | |
'first_retrieve', 'first_retrieve', | |
'view_counter', 'view_counter', | |
'thread_update_time', 'thread_update_time', | |
'num_res', 'num_res', | |
'mylist_counter', 'mylist_counter', | |
'length_seconds', 'length_seconds' | |
])[sortId]; | |
if (format === 'www' && sortKey === 'mylist_comment') { | |
sortKey = 'description'; | |
} | |
if (format === 'www' && sortKey === 'thread_update_time') { | |
sortKey = 'update_time'; | |
} | |
let order; | |
switch (sortKey) { | |
case 'first_retrieve': | |
case 'thread_update_time': | |
case 'update_time': | |
order = (sortId % 2 === 1) ? 'asc' : 'desc'; | |
break; | |
case 'num_res': | |
case 'mylist_counter': | |
case 'view_counter': | |
case 'length_seconds': | |
order = (sortId % 2 === 1) ? 'asc' : 'desc'; | |
break; | |
default: | |
order = (sortId % 2 === 0) ? 'asc' : 'desc'; | |
} | |
if (!sortKey) { | |
return items; | |
} | |
let getKeyFunc = (function (sortKey, format) { | |
switch (sortKey) { | |
case 'create_time': | |
case 'description': | |
case 'mylist_comment': | |
case 'update_time': | |
return item => item[sortKey]; | |
case 'num_res': | |
case 'mylist_counter': | |
case 'view_counter': | |
case 'length_seconds': | |
if (format === 'flapi') { | |
return item => item[sortKey] * 1; | |
} else { | |
return item => item.item_data[sortKey] * 1; | |
} | |
default: | |
if (format === 'flapi') { | |
return item => item[sortKey]; | |
} else { | |
return item => item.item_data[sortKey]; | |
} | |
} | |
})(sortKey, format); | |
let compareFunc = (function (order, getKey) { | |
switch (order) { | |
case 'asc': | |
return function (a, b) { | |
let ak = getKey(a), bk = getKey(b); | |
if (ak !== bk) { | |
return ak > bk ? 1 : -1; | |
} | |
else { | |
return a.id > b.id ? 1 : -1; | |
} | |
}; | |
case 'desc': | |
return function (a, b) { | |
let ak = getKey(a), bk = getKey(b); | |
if (ak !== bk) { | |
return (ak < bk) ? 1 : -1; | |
} | |
else { | |
return a.id < b.id ? 1 : -1; | |
} | |
}; | |
} | |
})(order, getKeyFunc); | |
items.sort(compareFunc); | |
return items; | |
} | |
async getMylistList() { | |
const url = 'https://www.nicovideo.jp/api/mylistgroup/list'; | |
const cacheKey = 'mylistList'; | |
const cacheData = cacheStorage.getItem(cacheKey); | |
if (cacheData) { | |
return cacheData; | |
} | |
const result = await netUtil.fetch(url, {credentials: 'include'}) | |
.then(r => r.json()) | |
.catch(e => { throw new Error('マイリスト一覧の取得失敗(2)', e); }); | |
if (result.status !== 'ok' || !result.mylistgroup) { | |
throw new Error(`マイリスト一覧の取得失敗(1) ${result.status}${result.message}`, result); | |
} | |
const data = result.mylistgroup; | |
cacheStorage.setItem(cacheKey, data, CACHE_EXPIRE_TIME); | |
return data; | |
} | |
async findDeflistItemByWatchId(watchId) { | |
const items = await this.getDeflistItems().catch(() => []); | |
for (let i = 0, len = items.length; i < len; i++) { | |
let item = items[i], wid = item.id || item.item_data.watch_id; | |
if (wid === watchId) { | |
return item; | |
} | |
} | |
return Promise.reject(); | |
} | |
async findMylistItemByWatchId(watchId, groupId) { | |
const items = await this._getMylistItemsFromWapi(groupId).catch(() => []); | |
for (let i = 0, len = items.length; i < len; i++) { | |
let item = items[i], wid = item.id || item.item_data.watch_id; | |
if (wid === watchId) { | |
return item; | |
} | |
} | |
return Promise.reject(); | |
} | |
async _getMylistItemsFromWapi(groupId) { | |
const url = `https://www.nicovideo.jp/api/mylist/list?group_id=${groupId}}`; | |
const result = await netUtil.fetch(url, {credentials: 'include'}) | |
.then(r => r.json()) | |
.catch(e => { throw new Error('マイリスト取得失敗(2)', e); }); | |
if (!result || result.status !== 'ok' && !result.mylistitem) { | |
window.console.info('getMylistItems fail', result); | |
throw new Error('マイリスト取得失敗(1)', result); | |
} | |
return result.mylistitem; | |
} | |
async removeDeflistItem(watchId) { | |
const item = await this.findDeflistItemByWatchId(watchId).catch(() => { | |
throw new Error('動画が見つかりません'); | |
}); | |
const url = 'https://www.nicovideo.jp/api/deflist/delete'; | |
const body = `id_list[0][]=${item.item_id}&token=${token}`; | |
const cacheKey = 'deflistItems'; | |
const req = { | |
method: 'POST', | |
body, | |
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, | |
credentials: 'include' | |
}; | |
const result = await netUtil.fetch(url, req) | |
.then(r => r.json()).catch(e => e || {}); | |
if (result && result.status && result.status === 'ok') { | |
cacheStorage.removeItem(cacheKey); | |
emitter.emitAsync('deflistRemove', watchId); | |
return { | |
status: 'ok', | |
result: result, | |
message: 'とりあえずマイリストから削除' | |
}; | |
} | |
throw new Error(result.error.description, { | |
status: 'fail', result, code: result.error.code | |
}); | |
} | |
async removeMylistItem(watchId, groupId) { | |
const item = await this.findMylistItemByWatchId(watchId, groupId).catch(result => { | |
throw new Error('動画が見つかりません', {result, status: 'fail'}); | |
}); | |
const url = 'https://www.nicovideo.jp/api/mylist/delete'; | |
window.console.log('delete item:', item); | |
const body = 'id_list[0][]=' + item.item_id + '&token=' + token + '&group_id=' + groupId; | |
const cacheKey = `mylistItems: ${groupId}`; | |
const result = await netUtil.fetch(url, { | |
method: 'POST', | |
body, | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded'}, | |
credentials: 'include' | |
}).then(r => r.json()) | |
.catch(result => { | |
throw new Error('マイリストから削除失敗(2)', {result, status: 'fail'}); | |
}); | |
if (result.status && result.status === 'ok') { | |
cacheStorage.removeItem(cacheKey); | |
emitter.emitAsync('mylistRemove', watchId, groupId); | |
return { | |
status: 'ok', | |
result, | |
message: 'マイリストから削除' | |
}; | |
} | |
throw new Error(result.error.description, { | |
status: 'fail', | |
result, | |
code: result.error.code | |
}); | |
} | |
async _addDeflistItem(watchId, description, isRetry) { | |
let url = 'https://www.nicovideo.jp/api/deflist/add'; | |
let body = `item_id=${watchId}&token=${token}`; | |
if (description) { | |
body += `&description=${encodeURIComponent(description)}`; | |
} | |
let cacheKey = 'deflistItems'; | |
const result = await netUtil.fetch(url, { | |
method: 'POST', | |
body, | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded'}, | |
credentials: 'include' | |
}).then(r => r.json()) | |
.catch(err => { | |
throw new Error('とりあえずマイリスト登録失敗(200)', { | |
status: 'fail', | |
result: err | |
}); | |
}); | |
if (result.status && result.status === 'ok') { | |
cacheStorage.removeItem(cacheKey); | |
emitter.emitAsync('deflistAdd', watchId, description); | |
return { | |
status: 'ok', | |
result, | |
message: 'とりあえずマイリスト登録' | |
}; | |
} | |
if (!result.status || !result.error) { | |
throw new Error('とりあえずマイリスト登録失敗(100)', { | |
status: 'fail', | |
result, | |
}); | |
} | |
if (result.error.code !== 'EXIST' || isRetry) { | |
throw new Error(result.error.description, { | |
status: 'fail', | |
result, | |
code: result.error.code, | |
message: result.error.description | |
}); | |
} | |
await self.removeDeflistItem(watchId).catch(err => { | |
throw new Error('とりあえずマイリスト登録失敗(101)', { | |
status: 'fail', | |
result: err.result, | |
code: err.code | |
}); | |
}); | |
const added = await self._addDeflistItem(watchId, description, true); | |
return { | |
status: 'ok', | |
result: added, | |
message: 'とりあえずマイリストの先頭に移動' | |
}; | |
} | |
addDeflistItem(watchId, description) { | |
return this._addDeflistItem(watchId, description, false); | |
} | |
async addMylistItem(watchId, groupId, description) { | |
const url = 'https://www.nicovideo.jp/api/mylist/add'; | |
let body = 'item_id=' + watchId + '&token=' + token + '&group_id=' + groupId; | |
if (description) { | |
body += '&description=' + encodeURIComponent(description); | |
} | |
const cacheKey = `mylistItems: ${groupId}`; | |
const result = await netUtil.fetch(url, { | |
method: 'POST', | |
body, | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded'}, | |
credentials: 'include' | |
}).then(r => r.json()) | |
.catch(err => { | |
throw new Error('マイリスト登録失敗(200)', { | |
status: 'fail', | |
result: err | |
}); | |
}); | |
if (result.status && result.status === 'ok') { | |
cacheStorage.removeItem(cacheKey); | |
this.removeDeflistItem(watchId).catch(() => {}); | |
return {status: 'ok', result, message: 'マイリスト登録'}; | |
} | |
if (!result.status || !result.error) { | |
throw new Error('マイリスト登録失敗(100)', {status: 'fail', result}); | |
} | |
emitter.emitAsync('mylistAdd', watchId, groupId, description); | |
throw new Error(result.error.description, { | |
status: 'fail', result, code: result.error.code | |
}); | |
} | |
} | |
return new MylistApiLoader(); | |
})(); | |
const NicoRssLoader = (() => { | |
const parseItem = item => { | |
const id = item.querySelector('link').textContent.replace(/^.+\//, ''); | |
let watchId = id; | |
const guid = item.querySelector('guid').textContent; | |
const desc = new DOMParser().parseFromString(item.querySelector('description').textContent, 'text/html'); | |
const [min, sec] = desc.querySelector('.nico-info-length').textContent.split(':'); | |
const dt = guid.match(/,([\d]+-[\d]+-[\d]+):/)[1]; | |
const tm = desc.querySelector('.nico-info-date').textContent.replace(/[:]/g, ':').match(/([\d]+:[\d]+:[\d]+)/)[0]; | |
const date = new Date(`${dt} ${tm}`); | |
const thumbnail_url = desc.querySelector('.nico-thumbnail img').src; | |
const vm = thumbnail_url.match(/(\d+)\.(\d+)/); | |
if (vm && /^\d+$/.test(id)) { | |
watchId = `so${vm[1]}`; | |
} | |
const result = { | |
_format: 'nicorss', | |
id: watchId, | |
uniq_id: id, | |
title: item.querySelector('title').textContent, | |
length_seconds: min * 60 + sec * 1, | |
thumbnail_url, | |
first_retrieve: textUtil.dateToString(date), | |
description: desc.querySelector('.nico-description').textContent | |
}; | |
if (desc.querySelector('.nico-info-total-res')) { | |
Object.assign(result, { | |
num_res: parseInt(desc.querySelector('.nico-info-total-res').textContent.replace(/,/g, ''), 10), | |
mylist_counter: parseInt(desc.querySelector('.nico-info-total-mylist').textContent.replace(/,/g, ''), 10), | |
view_counter: parseInt(desc.querySelector('.nico-info-total-view').textContent.replace(/,/g, ''), 10) | |
}); | |
} | |
return result; | |
}; | |
const load = async (url) => { | |
const rssText = await netUtil.fetch(url).then(r => r.text()); | |
const xml = new DOMParser().parseFromString(rssText, 'application/xml'); | |
const items = Array.from(xml.querySelectorAll('item')).map(i => parseItem(i)); | |
return items; | |
}; | |
const loadRanking = ({genre = 'all', term = 'hour', tag = ''}) => { | |
const url = `https://www.nicovideo.jp/ranking/genre/${genre}?term=${term}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}&rss=2.0`; | |
return load(url); | |
}; | |
return { | |
load, | |
loadRanking | |
}; | |
})(); | |
const MatrixRankingLoader = { | |
load: async () => { | |
const htmlText = await netUtil.fetch( | |
'https://www.nicovideo.jp/ranking', {cledentials: 'include'} | |
).then(r => r.text()); | |
const doc = new DOMParser().parseFromString(htmlText, 'text/html'); | |
return JSON.parse(doc.getElementById('MatrixRanking-app').dataset.app); | |
} | |
}; | |
const IchibaLoader = { | |
load: watchId => { | |
const api = 'https://ichiba.nicovideo.jp/embed/zero/show_ichiba'; | |
const country = 'ja-jp'; | |
const url = `${api}?v=${watchId}&country=${country}&ch=&is_adult=1&rev=20120220`; | |
return netUtil.jsonp(url); | |
} | |
}; | |
const CommonsTreeLoader = { | |
load: contentId => { | |
const api = 'https://api.commons.nicovideo.jp/tree/summary/get'; | |
const url = `${api}?id=${contentId}&limit=200`; | |
return netUtil.jsonp(url); | |
} | |
}; | |
const UploadedVideoApiLoader = (() => { | |
let loader = null; | |
class UploadedVideoApiLoader { | |
async load(userId) { | |
const url = `https://flapi.nicovideo.jp/api/watch/uploadedvideo?user_id=${userId}`; | |
const result = await netUtil.fetch(url, {credentials: 'include'}) | |
.then(r => r.json()) | |
.catch(e => { throw new Error('動画一覧の取得失敗(2)', e); }); | |
if (result.status !== 'ok' || !result.list) { | |
throw new Error(`動画一覧の取得失敗(1) ${result && result.message}`, result); | |
} | |
return result.list; | |
} | |
} | |
return { | |
load: userId => { | |
loader = loader || new UploadedVideoApiLoader(); | |
return loader.load(userId); | |
}, | |
getUploadedVideos: userId => { | |
loader = loader || new UploadedVideoApiLoader(); | |
return loader.load(userId); | |
} | |
}; | |
})(); | |
const UaaLoader = { | |
load: (videoId, {limit = 50} = {}) => { | |
const url = `https://api.nicoad.nicovideo.jp/v1/contents/video/${videoId}/thanks?limit=${limit}`; | |
return netUtil.fetch(url, {credentials: 'include'}).then(res => res.json()); | |
} | |
}; | |
const RecommendAPILoader = (() => { | |
const load = ({videoId, recipe}) => { | |
recipe = recipe || {id: 'video_playlist_common', videoId}; | |
recipe = textUtil.encodeBase64(JSON.stringify(recipe)); | |
const url = `https://nvapi.nicovideo.jp/v1/recommend?recipe=${encodeURIComponent(recipe)}&site=nicovideo&_frontendId=6&_frontendVersion=0`; | |
return netUtil | |
.fetch(url, {credentials: 'include'}) | |
.then(res => res.json()) | |
.then(res => { | |
if (!res.meta || res.meta.status !== 200) { | |
window.console.warn('load recommend fail', res); | |
throw new Error('load recommend fail'); | |
} | |
return res.data; | |
}); | |
}; | |
return { | |
load, | |
loadSeries: (seriesId, options = {}) => { | |
const recipe = { | |
id: 'video_watch_playlist_series', | |
seriesId, | |
frontendId: 6, | |
seriesTitle: options.title || `series/${seriesId}` | |
}; | |
return load({recipe}); | |
} | |
}; | |
})(); | |
const NVWatchCaller = (() => { | |
const FRONT_ID = '6'; | |
const FRONT_VER = '0'; | |
const call = trackingId => { | |
const url = `https://nvapi.nicovideo.jp/v1/2ab0cbaa/watch?t=${encodeURIComponent(trackingId)}`;//&_frontendId=${FRONT_ID}`; | |
return netUtil | |
.fetch(url, { | |
mode: 'cors', | |
credentials: 'include', | |
timeout: 5000, | |
headers: { | |
'X-Frontend-Id': FRONT_ID, | |
'X-Frontend-Version': FRONT_VER | |
} | |
}) | |
.catch(e => { | |
console.warn('nvlog fail', e); | |
}); | |
}; | |
return {call}; | |
})(); | |
const PlaybackPosition = { | |
record: (watchId, playbackPosition, csrfToken) => { | |
const url = 'https://nvapi.nicovideo.jp/v1/users/me/watch/history/playback-position'; | |
const body = | |
`watchId=${watchId}&seconds=${playbackPosition}`; | |
return netUtil.fetch(url, { | |
method: 'PUT', | |
credentials: 'include', | |
headers: { | |
'Content-Type': 'application/x-www-form-urlencoded' | |
}, | |
body | |
}); | |
} | |
}; | |
class CrossDomainGate extends Emitter { | |
static get hostReg() { | |
return /^[a-z0-9]*\.nicovideo\.jp$/; | |
} | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
initialize(params) { | |
this._baseUrl = params.baseUrl; | |
this._origin = params.origin || location.href; | |
this._type = params.type; | |
this._suffix = params.suffix || ''; | |
this.name = params.name || params.type; | |
this._sessions = {}; | |
this._initializeStatus = 'none'; | |
} | |
_initializeFrame() { | |
if (this._initializeStatus !== 'none') { | |
return this.promise('initialize'); | |
} | |
this._initializeStatus = 'initializing'; | |
const append = () => { | |
if (!this.loaderFrame.parentNode) { | |
console.warn('frame removed'); | |
this.port = null; | |
this._initializeCrossDomainGate(); | |
} | |
}; | |
setTimeout(append, 5 * 1000); | |
setTimeout(append, 10 * 1000); | |
setTimeout(append, 20 * 1000); | |
setTimeout(append, 30 * 1000); | |
setTimeout(() => { | |
if (this._initializeStatus === 'done') { | |
return; | |
} | |
this.emitReject('initialize', { | |
status: 'timeout', message: `CrossDomainGate初期化タイムアウト (type: ${this._type}, status: ${this._initializeStatus})` | |
}); | |
console.warn(`CrossDomainGate初期化タイムアウト (type: ${this._type}, status: ${this._initializeStatus})`); | |
}, 60 * 1000); | |
this._initializeCrossDomainGate(); | |
return this.promise('initialize'); | |
} | |
_initializeCrossDomainGate() { | |
window.console.time(`GATE OPEN: ${this.name} ${PRODUCT}`); | |
const loaderFrame = this.loaderFrame = document.createElement('iframe'); | |
loaderFrame.referrerPolicy = 'origin'; | |
loaderFrame.sandbox = 'allow-scripts allow-same-origin'; | |
loaderFrame.loading = 'eager'; | |
loaderFrame.name = `${this._type}${PRODUCT}Loader${this._suffix ? `#${this._suffix}` : ''}`; | |
loaderFrame.className = `xDomainLoaderFrame ${this._type}`; | |
loaderFrame.style.cssText = ` | |
position: fixed; left: -100vw; pointer-events: none;user-select: none; contain: strict;`; | |
(document.body || document.documentElement).append(loaderFrame); | |
this._loaderWindow = loaderFrame.contentWindow; | |
const onInitialMessage = event => { | |
if (event.source !== this._loaderWindow) { | |
return; | |
} | |
window.removeEventListener('message', onInitialMessage); | |
this._onMessage(event); | |
}; | |
window.addEventListener('message', onInitialMessage, {capture: true}); | |
this._loaderWindow.location.replace(this._baseUrl + '#' + TOKEN); | |
} | |
_onMessage(event) { | |
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; | |
const {id, type, token, sessionId, body} = data; | |
if (id !== PRODUCT || type !== this._type || token !== TOKEN) { | |
console.warn('invalid token:', | |
{id, PRODUCT, type, _type: this._type, token, TOKEN}); | |
return; | |
} | |
if (!this.port && body.command === 'initialized') { | |
const port = this.port = event.ports[0]; | |
port.addEventListener('message', this._onMessage.bind(this)); | |
port.start(); | |
port.postMessage({body: {command: 'ok'}, token: TOKEN}); | |
} | |
return this._onCommand(body, sessionId); | |
} | |
_onCommand({command, status, params}, sessionId = null) { | |
switch (command) { | |
case 'initialized': | |
if (this._initializeStatus !== 'done') { | |
this._initializeStatus = 'done'; | |
const originalBody = params; | |
window.console.timeEnd(`GATE OPEN: ${this.name} ${PRODUCT}`); | |
const result = this._onCommand(originalBody, sessionId); | |
this.emitResolve('initialize', {status: 'ok'}); | |
return result; | |
} | |
break; | |
case 'message': | |
BroadcastEmitter.emitAsync('message', params, 'broadcast', sessionId); | |
break; | |
default: { | |
const session = this._sessions[sessionId]; | |
if (!session) { | |
return; | |
} | |
if (status === 'ok') { | |
session.resolve(params); | |
} else { | |
session.reject({message: status || 'fail'}); | |
} | |
delete this._sessions[sessionId]; | |
} | |
break; | |
} | |
} | |
load(url, options) { | |
return this._postMessage({command: 'loadUrl', params: {url, options}}); | |
} | |
videoCapture(src, sec) { | |
return this._postMessage({command: 'videoCapture', params: {src, sec}}) | |
.then(result => Promise.resolve(result.dataUrl)); | |
} | |
_fetch(url, options) { | |
return this._postMessage({command: 'fetch', params: {url, options}}); | |
} | |
async fetch(url, options = {}) { | |
const result = await this._fetch(url, options); | |
if (typeof result === 'string' || !result.buffer || !result.init || !result.headers) { | |
return result; | |
} | |
const {buffer, init, headers} = result; | |
const _headers = new Headers(); | |
(headers || []).forEach(a => _headers.append(...a)); | |
const _init = { | |
status: init.status, | |
statusText: init.statusText || '', | |
headers: _headers | |
}; | |
if (options._format === 'arraybuffer') { | |
return {buffer, init, headers}; | |
} | |
return new Response(buffer, _init); | |
} | |
async configBridge(config) { | |
const keys = config.getKeys(); | |
this._config = config; | |
const configData = await this._postMessage({ | |
command: 'dumpConfig', | |
params: { keys, url: '', prefix: PRODUCT } | |
}); | |
for (const key of Object.keys(configData)) { | |
config.props[key] = configData[key]; | |
} | |
if (!this.constructor.hostReg.test(location.host) && | |
!config.props.allowOtherDomain) { | |
return; | |
} | |
config.on('update', (key, value) => { | |
if (key === 'autoCloseFullScreen') { | |
return; | |
} | |
this._postMessage({command: 'saveConfig', params: {key, value, prefix: PRODUCT}}, false); | |
}); | |
} | |
async _postMessage(body, usePromise = true, sessionId = '') { | |
await this._initializeFrame(); | |
sessionId = sessionId || (`gate:${Math.random()}`); | |
const {params} = body; | |
return this._sessions[sessionId] = | |
new PromiseHandler((resolve, reject) => { | |
try { | |
this.port.postMessage({body, sessionId, token: TOKEN}, params.transfer); | |
if (!usePromise) { | |
delete this._sessions[sessionId]; | |
resolve(); | |
} | |
} catch (error) { | |
console.log('%cException!', 'background: red;', {error, body}); | |
delete this._sessions[sessionId]; | |
reject(error); | |
} | |
}); | |
} | |
postMessage(body, promise = true) { | |
return this._postMessage(body, promise); | |
} | |
sendMessage(body, usePromise = false, sessionId = '') { | |
return this._postMessage({command: 'message', params: body}, usePromise, sessionId); | |
} | |
pushHistory(path, title) { | |
return this._postMessage({command: 'pushHistory', params: {path, title}}, false); | |
} | |
async bridgeDb({name, ver, stores}) { | |
const worker = await this._postMessage( | |
{command: 'bridge-db', params: {command: 'open', params: {name, ver, stores}}} | |
); | |
const post = (command, data, storeName, transfer) => { | |
const params = {data, storeName, transfer, name}; | |
return this._postMessage({command: 'bridge-db', params: {command, params, transfer}}); | |
}; | |
const result = {worker}; | |
for (const meta of stores) { | |
const storeName = meta.name; | |
result[storeName] = (storeName => { | |
return { | |
close: params => post('close', params, storeName), | |
put: (record, transfer) => post('put', record, storeName, transfer), | |
get: ({key, index, timeout}) => post('get', {key, index, timeout}, storeName), | |
updateTime: ({key, index, timeout}) => post('updateTime', {key, index, timeout}, storeName), | |
delete: ({key, index, timeout}) => post('delete', {key, index, timeout}, storeName), | |
gc: (expireTime = 30 * 24 * 60 * 60 * 1000, index = 'updatedAt') => post('gc', {expireTime, index}, storeName) | |
}; | |
})(storeName); | |
} | |
return result; | |
} | |
} | |
const NicoVideoApi = (() => { | |
let gate = null; | |
const init = () => { | |
if (gate) { return gate; } | |
if (location.host === 'www.nicovideo.jp') { | |
return gate = {}; | |
} | |
class NVGate extends CrossDomainGate { | |
_onCommand({command, status, params, value}, sessionId = null) { | |
switch (command) { | |
case 'configSync': | |
this._config.props[params.key] = params.value; | |
break; | |
default: | |
return super._onCommand({command, status, params, value}, sessionId); | |
} | |
} | |
} | |
return gate = new NVGate({ | |
baseUrl: 'https://www.nicovideo.jp/robots.txt', | |
origin: 'https://www.nicovideo.jp/', | |
type: 'nicovideoApi', | |
suffix: location.href | |
}); | |
}; | |
return { | |
fetch(...args) { return init().fetch(...args); }, | |
configBridge(...args) { return init().configBridge(...args); }, | |
postMessage(...args) { return init().postMessage(...args); }, | |
sendMessage(...args) { return init().sendMessage(...args); }, | |
pushHistory(...args) { return init().pushHistory(...args); }, | |
bridgeDb(...args) { return init().bridgeDb(...args); } | |
}; | |
})(); | |
class DmcInfo { | |
constructor(rawData) { | |
this._rawData = rawData; | |
this._session = rawData.movie.session; | |
} | |
get apiUrl() { | |
return this._session.urls[0].url; | |
} | |
get urls() { | |
return this._session.urls; | |
} | |
get audios() { | |
return this._session.audios; | |
} | |
get videos() { | |
return this._rawData.movie.videos; | |
} | |
get quality() { | |
return this._rawData.movie.quality; | |
} | |
get signature() { | |
return this._session.signature; | |
} | |
get token() { | |
return this._session.token; | |
} | |
get serviceUserId() { | |
return this._session.serviceUserId; | |
} | |
get contentId() { | |
return this._session.contentId; | |
} | |
get playerId() { | |
return this._session.playerId; | |
} | |
get recipeId() { | |
return this._session.recipeId; | |
} | |
get heartBeatLifeTimeMs() { | |
return this._session.heartbeatLifetime; | |
} | |
get protocols() { | |
return this._session.protocols || []; | |
} | |
get isHLSRequired() { | |
return !this.protocols.includes('http'); | |
} | |
get contentKeyTimeout() { | |
return this._session.contentKeyTimeout; | |
} | |
get priority() { | |
return this._session.priority; | |
} | |
get authTypes() { | |
return this._session.authTypes; | |
} | |
get videoFormatList() { | |
return (this.videos || []).concat(); | |
} | |
get hasStoryboard() { | |
return !!this._rawData.storyboard_session_api; | |
} | |
get storyboardInfo() { | |
return this._rawData.storyboard_session_api; | |
} | |
get transferPreset() { | |
return (this._session.transferPresets || [''])[0] || ''; | |
} | |
get heartbeatLifeTime() { | |
return this._session.heartbeatLifetime || 120 * 1000; | |
} | |
get importVersion() { | |
return this._rawData.import_version || 0; | |
} | |
get trackingId() { | |
return this._rawData.trackingId || ''; | |
} | |
get encryption() { | |
return this._rawData.encryption || null; | |
} | |
getData() { | |
const data = {}; | |
for (const prop of Object.getOwnPropertyNames(this.constructor.prototype)) { | |
if (typeof this[prop] === 'function') { continue; } | |
data[prop] = this[prop]; | |
} | |
return data; | |
} | |
toJSON() { | |
return JSON.stringify(this.getData()); | |
} | |
} | |
class VideoFilter { | |
constructor(ngOwner, ngTag) { | |
this.ngOwner = ngOwner; | |
this.ngTag = ngTag; | |
} | |
get ngOwner() { | |
return this._ngOwner || []; | |
} | |
set ngOwner(owner) { | |
owner = _.isArray(owner) ? owner : owner.toString().split(/[\r\n]/); | |
let list = []; | |
owner.forEach(o => { | |
list.push(o.replace(/#.*$/, '').trim()); | |
}); | |
this._ngOwner = list; | |
} | |
get ngTag() { | |
return this._ngTag || []; | |
} | |
set ngTag(tag) { | |
tag = Array.isArray(tag) ? tag : tag.toString().split(/[\r\n]/); | |
const list = []; | |
tag.forEach(t => { | |
list.push(t.toLowerCase().trim()); | |
}); | |
this._ngTag = list; | |
} | |
isNgVideo(videoInfo) { | |
let isNg = false; | |
let isChannel = videoInfo.isChannel; | |
let ngTag = this.ngTag; | |
videoInfo.tagList.forEach(tag => { | |
let text = (tag.tag || '').toLowerCase(); | |
if (ngTag.includes(text)) { | |
isNg = true; | |
} | |
}); | |
if (isNg) { | |
return true; | |
} | |
let owner = videoInfo.owner; | |
let ownerId = isChannel ? ('ch' + owner.id) : owner.id; | |
if (ownerId && this.ngOwner.includes(ownerId)) { | |
isNg = true; | |
} | |
return isNg; | |
} | |
} | |
class VideoInfoModel { | |
constructor(videoInfoData, localCacheData = {}) { | |
this._update(videoInfoData, localCacheData); | |
this._currentVideoPromise = null; | |
} | |
update(videoInfoModel) { | |
this._update(videoInfoModel._rawData); | |
return true; | |
} | |
_update(info, localCacheData = {}) { | |
this._rawData = info; | |
this._cacheData = localCacheData; | |
this._watchApiData = info.watchApiData; | |
this._videoDetail = info.watchApiData.videoDetail; | |
this._flashvars = info.watchApiData.flashvars; // flashに渡す情報 | |
this._viewerInfo = info.viewerInfo; // 閲覧者(=おまいら)の情報 | |
this._flvInfo = info.flvInfo; | |
this._msgInfo = info.msgInfo; | |
this._dmcInfo = (info.dmcInfo && info.dmcInfo.movie.session) ? new DmcInfo(info.dmcInfo) : null; | |
this._relatedVideo = info.playlist; // playlistという名前だが実質は関連動画 | |
this._playlistToken = info.playlistToken; | |
this._watchAuthKey = info.watchAuthKey; | |
this._seekToken = info.seekToken; | |
this._resumeInfo = info.resumeInfo || {}; | |
this._currentVideo = null; | |
this._currentVideoPromise = null; | |
return true; | |
} | |
get title() { | |
return this._videoDetail.title_original || this._videoDetail.title; | |
} | |
get description() { | |
return this._videoDetail.description || ''; | |
} | |
get descriptionOriginal() { | |
return this._videoDetail.description_original; | |
} | |
get postedAt() { | |
return this._videoDetail.postedAt; | |
} | |
get thumbnail() { | |
return this._videoDetail.thumbnail; | |
} | |
get betterThumbnail() { | |
return this._rawData.thumbnail; | |
} | |
get largeThumbnnail() { | |
return this._videoDetail.largeThumbnnail; | |
} | |
get videoUrl() { | |
return (this._flvInfo.url || '');//.replace(/^http:/, ''); | |
} | |
get storyboardUrl() { | |
let url = this._flvInfo.url; | |
if (!url || !url.match(/smile\?m=/) || url.match(/^rtmp/)) { | |
return null; | |
} | |
return url; | |
} | |
getCurrentVideo() { | |
if (this._currentVideoPromise) { | |
return this._currentVideoPromise; | |
} | |
return this._currentVideoPromise = new PromiseHandler(); | |
} | |
setCurrentVideo(v) { | |
this._currentVideo = v; | |
this._currentVideoPromise && this._currentVideoPromise.resolve(v); | |
} | |
get isEconomy() { | |
return this.videoUrl.match(/low$/) ? true : false; | |
} | |
get tagList() { | |
return this._videoDetail.tagList; | |
} | |
getVideoId() { // sm12345 | |
return this.videoId; | |
} | |
get videoId() { | |
return this._videoDetail.id; | |
} | |
get originalVideoId() { | |
return (this.isMymemory || this.isCommunityVideo) ? this.videoId : ''; | |
} | |
getWatchId() { // sm12345だったりスレッドIDだったり | |
return this.watchId; | |
} | |
get watchId() { | |
if (this.videoId.substring(0, 2) === 'so') { | |
return this.videoId; | |
} | |
return this._videoDetail.v; | |
} | |
get contextWatchId() { | |
return this._videoDetail.v; | |
} | |
get watchUrl() { | |
return `https://www.nicovideo.jp/watch/${this.watchId}`; | |
} | |
get threadId() { // watchIdと同一とは限らない | |
return this._videoDetail.thread_id; | |
} | |
get videoSize() { | |
return { | |
width: this._videoDetail.width, | |
height: this._videoDetail.height | |
}; | |
} | |
get duration() { | |
return this._videoDetail.length; | |
} | |
get count() { | |
const vd = this._videoDetail; | |
return { | |
comment: vd.commentCount, | |
mylist: vd.mylistCount, | |
view: vd.viewCount | |
}; | |
} | |
get isChannel() { | |
return !!this._videoDetail.channelId; | |
} | |
get isMymemory() { | |
return !!this._videoDetail.isMymemory; | |
} | |
get isCommunityVideo() { | |
return !!(!this.isChannel && this._videoDetail.communityId); | |
} | |
get isPremiumOnly() { | |
return !!this._videoDetail.isPremiumOnly; | |
} | |
get isLiked() { | |
return !!this._videoDetail.isLiked; | |
} | |
set isLiked(v) { | |
this._videoDetail.isLiked = v; | |
} | |
get hasParentVideo() { | |
return !!(this._videoDetail.commons_tree_exists); | |
} | |
get isDmc() { | |
return this.isDmcOnly || (this._rawData.isDmc); | |
} | |
get isDmcAvailable() { | |
return this._rawData.isDmc; | |
} | |
get dmcInfo() { | |
return this._dmcInfo; | |
} | |
get msgInfo() { | |
return this._msgInfo; | |
} | |
get isDmcOnly() { | |
return !!this._rawData.isDmcOnly || !this.videoUrl; | |
} | |
get hasDmcStoryboard() { | |
return this._dmcInfo && this._dmcInfo.hasStoryboard; | |
} | |
get dmcStoryboardInfo() { | |
return !!this._dmcInfo ? this._dmcInfo.storyboardInfo : null; | |
} | |
get owner() { | |
let ownerInfo; | |
if (this.isChannel) { | |
let c = this._watchApiData.channelInfo || {}; | |
ownerInfo = { | |
icon: c.icon_url || 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', | |
url: `https://ch.nicovideo.jp/ch${c.id}`, | |
id: c.id, | |
linkId: c.id ? `ch${c.id}` : '', | |
name: c.name, | |
favorite: c.is_favorited === 1, // こっちは01で | |
type: 'channel' | |
}; | |
} else { | |
let u = this._watchApiData.uploaderInfo || {}; | |
let f = this._flashvars || {}; | |
ownerInfo = { | |
icon: u.icon_url || 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', | |
url: u.id ? `//www.nicovideo.jp/user/${u.id}` : '#', | |
id: u.id || f.videoUserId || '', | |
linkId: u.id ? `user/${u.id}` : '', | |
name: u.nickname || '(非公開ユーザー)', | |
favorite: !!u.is_favorited, // こっちはbooleanという | |
type: 'user', | |
isMyVideoPublic: !!u.is_user_myvideo_public | |
}; | |
} | |
return ownerInfo; | |
} | |
get series() { | |
if (!this._rawData.series || !this._rawData.series.id) { | |
return null; | |
} | |
const series = this._rawData.series; | |
const thumbnailUrl = series.thumbnailUrl || this.betterThumbnail; | |
return Object.assign({}, series, {thumbnailUrl}); | |
} | |
get firstVideo() { | |
return this.series ? this.series.firstVideo : null; | |
} | |
get prevVideo() { | |
return this.series ? this.series.prevVideo : null; | |
} | |
get nextVideo() { | |
return this.series ? this.series.nextVideo : null; | |
} | |
get relatedVideoItems() { | |
return this._relatedVideo.playlist || []; | |
} | |
get replacementWords() { | |
if (!this._flvInfo.ng_up || this._flvInfo.ng_up === '') { | |
return null; | |
} | |
return textUtil.parseQuery( | |
this._flvInfo.ng_up || '' | |
); | |
} | |
get playlistToken() { | |
return this._playlistToken; | |
} | |
set playlistToken(v) { | |
this._playlistToken = v; | |
} | |
get watchAuthKey() { | |
return this._watchAuthKey; | |
} | |
set watchAuthKey(v) { | |
this._watchAuthKey = v; | |
} | |
get seekToken() { | |
return this._seekToken; | |
} | |
get width() { | |
return parseInt(this._videoDetail.width, 10); | |
} | |
get height() { | |
return parseInt(this._videoDetail.height, 10); | |
} | |
get initialPlaybackTime() { | |
return this.resumePoints[0] && (this.resumePoints[0].time || 0); | |
} | |
get resumePoints() { | |
const duration = this.duration; | |
const MARGIN = 10; | |
const resumePoints = | |
((this._cacheData && this._cacheData.resume) ? this._cacheData.resume : []) | |
.filter(({now, time}) => time > MARGIN && time < duration - MARGIN) | |
.map(({now, time}) => { return {now: new Date().toLocaleString(), time}; }) | |
.reverse(); | |
const lastResumePoint = this._resumeInfo ? this._resumeInfo.initialPlaybackPosition : 0; | |
lastResumePoint && resumePoints.unshift({now: '前回', time: lastResumePoint}); | |
return resumePoints; | |
} | |
get csrfToken() { | |
return this._rawData.csrfToken || ''; | |
} | |
get extension() { | |
if (this.isDmc) { | |
return 'mp4'; | |
} | |
const url = this.videoUrl; | |
if (url.match(/smile\?m=/)) { | |
return 'mp4'; | |
} | |
if (url.match(/smile\?v=/)) { | |
return 'flv'; | |
} | |
if (url.match(/smile\?s=/)) { | |
return 'swf'; | |
} | |
return 'unknown'; | |
} | |
get community() { | |
return this._rawData.community || null; | |
} | |
get maybeBetterQualityServerType() { | |
if (this.isDmcOnly) { | |
return 'dmc'; | |
} | |
if (this.isEconomy) { | |
return 'dmc'; | |
} | |
let dmcInfo = this.dmcInfo; | |
if (!dmcInfo) { | |
return 'smile'; | |
} | |
if (/smile\?[sv]=/.test(this.videoUrl)) { | |
return 'dmc'; | |
} | |
let smileWidth = this.width; | |
let smileHeight = this.height; | |
let dmcVideos = dmcInfo.videos; | |
let importVersion = dmcInfo.importVersion; | |
if (isNaN(smileWidth) || isNaN(smileHeight)) { | |
return 'dmc'; | |
} | |
if (smileWidth > 1280 || smileHeight > 720) { | |
return 'smile'; | |
} | |
if (smileHeight < 360) { | |
return 'smile'; | |
} | |
const highestDmc = Math.max(...dmcVideos.map(v => { | |
return (/_([0-9]+)p$/.exec(v)[1] || '') * 1; | |
})); | |
if (highestDmc >= 720) { | |
return 'dmc'; | |
} | |
if (smileHeight === 486 || smileHeight === 384) { | |
return 'smile'; | |
} | |
if (highestDmc >= smileHeight) { | |
return 'dmc'; | |
} | |
return 'dmc'; | |
} | |
getData() { | |
const data = {}; | |
for (const prop of Object.getOwnPropertyNames(this.constructor.prototype)) { | |
if (typeof this[prop] === 'function') { continue; } | |
data[prop] = this[prop]; | |
} | |
return data; | |
} | |
} | |
const {NicoSearchApiV2Query, NicoSearchApiV2Loader} = | |
(function () { | |
const BASE_URL = 'https://api.search.nicovideo.jp/api/v2/'; | |
const API_BASE_URL = `${BASE_URL}/video/contents/search`; | |
const MESSAGE_ORIGIN = 'https://api.search.nicovideo.jp/'; | |
const SORT = { | |
f: 'startTime', | |
v: 'viewCounter', | |
r: 'commentCounter', | |
m: 'mylistCounter', | |
l: 'lengthSeconds', | |
n: 'lastCommentTime', | |
h: '_hotMylistCounter', // 人気が高い順 | |
'_hot': '_hotMylistCounter', // 人気が高い順(↑と同じだけど互換用に残ってる) | |
'_popular': '_popular', // 並び順指定なしらしい | |
}; | |
const F_RANGE = { | |
U_1H: 4, | |
U_24H: 1, | |
U_1W: 2, | |
U_30D: 3 | |
}; | |
const L_RANGE = { | |
U_5MIN: 1, | |
O_20MIN: 2 | |
}; | |
let gate; | |
let initializeCrossDomainGate = function () { | |
initializeCrossDomainGate = function () { | |
}; | |
gate = new CrossDomainGate({ | |
baseUrl: BASE_URL, | |
origin: MESSAGE_ORIGIN, | |
type: 'searchApi', | |
messager: WindowMessageEmitter | |
}); | |
}; | |
class NicoSearchApiV2Query { | |
constructor(word, params = {}) { | |
if (word.searchWord) { | |
this._initialize(word.searchWord, word); | |
} else { | |
this._initialize(word, params); | |
} | |
} | |
get q() { | |
return this._q; | |
} | |
get targets() { | |
return this._targets; | |
} | |
get sort() { | |
return this._sort; | |
} | |
get order() { | |
return this._order; | |
} | |
get limit() { | |
return this._limit; | |
} | |
get offset() { | |
return this._offset; | |
} | |
get fields() { | |
return this._fields; | |
} | |
get context() { | |
return this._context; | |
} | |
get hotField() { | |
return this._hotField; | |
} | |
get hotFrom() { | |
return this._hotFrom; | |
} | |
get hotTo() { | |
return this._hotTo; | |
} | |
_initialize(word, params) { | |
if (params._now) { | |
this.now = params._now; | |
} | |
const sortTable = SORT; | |
this._filters = []; | |
this._q = word || params.searchWord || 'ZenzaWatch'; | |
this._targets = | |
params.searchType === 'tag' ? | |
['tagsExact'] : ['tagsExact', 'title', 'description']; | |
this._sort = | |
(params.order === 'd' ? '-' : '+') + | |
(params.sort && sortTable[params.sort] ? | |
sortTable[params.sort] : 'lastCommentTime'); | |
this._order = params.order === 'd' ? 'desc' : 'asc'; | |
this._limit = 100; | |
this._offset = Math.min( | |
params.page ? Math.max(parseInt(params.page, 10) - 1, 0) * 25 : 0, | |
1600 | |
); | |
this._fields = [ | |
'contentId', 'title', 'description', 'tags', 'categoryTags', | |
'viewCounter', 'commentCounter', 'mylistCounter', 'lengthSeconds', | |
'startTime', 'thumbnailUrl' | |
]; | |
this._context = 'ZenzaWatch'; | |
const n = new Date(), now = this.now; | |
if (/^._hot/.test(this.sort)) { | |
(() => { | |
this._hotField = 'mylistCounter'; | |
this._hotFrom = new Date(now - 1 * 24 * 60 * 60 * 1000); | |
this._hotTo = n; | |
this._sort = '-_hotMylistCounter'; | |
})(); | |
} | |
if (params.f_range && | |
[F_RANGE.U_1H, F_RANGE.U_24H, F_RANGE.U_1W, F_RANGE.U_30D] | |
.includes(params.f_range * 1)) { | |
this._filters.push(this._buildFRangeFilter(params.f_range * 1)); | |
} | |
if (params.l_range && | |
[L_RANGE.U_5MIN, L_RANGE.O_20MIN].includes(params.l_range * 1)) { | |
this._filters.push(this._buildLRangeFilter(params.l_range * 1)); | |
} | |
if (params.userId && (params.userId + '').match(/^\d+$/)) { | |
this._filters.push({type: 'equal', field: 'userId', value: params.userId * 1}); | |
} | |
if (params.channelId && (params.channelId + '').match(/^\d+$/)) { | |
this._filters.push({type: 'equal', field: 'channelId', value: params.channelId * 1}); | |
} | |
if (params.commentCount && (params.commentCount + '').match(/^[0-9]+$/)) { | |
this._filters.push({ | |
type: 'range', | |
field: 'commentCounter', | |
from: params.commentCount * 1 | |
}); | |
} | |
if (params.utimeFrom || params.utimeTo) { | |
this._filters.push(this._buildStartTimeRangeFilter({ | |
from: params.utimeFrom ? params.utimeFrom * 1 : 0, | |
to: params.utimeTo ? params.utimeTo * 1 : now | |
})); | |
} | |
if (params.dateFrom || params.dateTo) { | |
this._filters.push(this._buildStartTimeRangeFilter({ | |
from: params.dateFrom ? (new Date(params.dateFrom)).getTime() : 0, | |
to: params.dateTo ? (new Date(params.dateTo)).getTime() : now | |
})); | |
} | |
const dateReg = /^\d{4}-\d{2}-\d{2}$/; | |
if (dateReg.test(params.start) && dateReg.test(params.end)) { | |
this._filters.push(this._buildStartTimeRangeFilter({ | |
from: (new Date(params.start)).getTime(), | |
to: (new Date(params.end)).getTime() | |
})); | |
} | |
} | |
get stringfiedFilters() { | |
if (this._filters.length < 1) { | |
return ''; | |
} | |
const result = []; | |
const TIMEFIELDS = ['startTime']; | |
this._filters.forEach((filter) => { | |
let isTimeField = TIMEFIELDS.includes(filter.field); | |
if (!filter) { | |
return; | |
} | |
if (filter.type === 'equal') { | |
result.push(`filters[${filter.field}][0]=${filter.value}`); | |
} else if (filter.type === 'range') { | |
let from = isTimeField ? this._formatDate(filter.from) : filter.from; | |
if (filter.from) { | |
result.push(`filters[${filter.field}][gte]=${from}`); | |
} | |
if (filter.to) { | |
let to = isTimeField ? this._formatDate(filter.to) : filter.to; | |
result.push(`filters[${filter.field}][lte]=${to}`); | |
} | |
} | |
}); | |
return result.join('&'); | |
} | |
get filters() { | |
return this._filters; | |
} | |
_formatDate(time) { | |
const dt = new Date(time); | |
return dt.toISOString().replace(/\.\d*Z/, '') + '%2b00:00'; // '%2b00:00' | |
} | |
_buildStartTimeRangeFilter({from = 0, to}) { | |
const range = {field: 'startTime', type: 'range'}; | |
if (from !== undefined && to !== undefined) { | |
[from, to] = [from, to].sort(); // from < to になるように | |
} | |
if (from !== undefined) { | |
range.from = from; | |
} | |
if (to !== undefined) { | |
range.to = to; | |
} | |
return range; | |
} | |
_buildLengthSecondsRangeFilter({from, to}) { | |
const range = {field: 'lengthSeconds', type: 'range'}; | |
if (from !== undefined && to !== undefined) { | |
[from, to] = [from, to].sort(); // from < to になるように | |
} | |
if (from !== undefined) { | |
range.from = from; | |
} | |
if (to !== undefined) { | |
range.to = to; | |
} | |
return range; | |
} | |
_buildFRangeFilter(range) { | |
const now = this.now; | |
switch (range * 1) { | |
case F_RANGE.U_1H: | |
return this._buildStartTimeRangeFilter({ | |
from: now - 1000 * 60 * 60, | |
to: now | |
}); | |
case F_RANGE.U_24H: | |
return this._buildStartTimeRangeFilter({ | |
from: now - 1000 * 60 * 60 * 24, | |
to: now | |
}); | |
case F_RANGE.U_1W: | |
return this._buildStartTimeRangeFilter({ | |
from: now - 1000 * 60 * 60 * 24 * 7, | |
to: now | |
}); | |
case F_RANGE.U_30D: | |
return this._buildStartTimeRangeFilter({ | |
from: now - 1000 * 60 * 60 * 24 * 30, | |
to: now | |
}); | |
default: | |
return null; | |
} | |
} | |
_buildLRangeFilter(range) { | |
switch (range) { | |
case L_RANGE.U_5MIN: | |
return this._buildLengthSecondsRangeFilter({ | |
from: 0, | |
to: 60 * 5 | |
}); | |
case L_RANGE.O_20MIN: | |
return this._buildLengthSecondsRangeFilter({ | |
from: 60 * 20 | |
}); | |
} | |
} | |
toString() { | |
const result = []; | |
result.push('q=' + encodeURIComponent(this._q)); | |
result.push('targets=' + this.targets.join(',')); | |
result.push('fields=' + this.fields.join(',')); | |
result.push('_sort=' + encodeURIComponent(this.sort)); | |
result.push('_limit=' + this.limit); | |
result.push('_offset=' + this.offset); | |
result.push('_context=' + this.context); | |
if (this.sort === '-_hot') { | |
result.push('hotField=' + this.hotField); | |
result.push('hotFrom=' + this.hotFrom); | |
result.push('hotTo=' + this.hotTo); | |
} | |
const filters = this.stringfiedFilters; | |
if (filters) { | |
result.push(filters); | |
} | |
return result.join('&'); | |
} | |
set now(v) { | |
this._now = v; | |
} | |
get now() { | |
return this._now || Date.now(); | |
} | |
} | |
NicoSearchApiV2Query.SORT = SORT; | |
NicoSearchApiV2Query.F_RANGE = F_RANGE; | |
NicoSearchApiV2Query.L_RANGE = L_RANGE; | |
class NicoSearchApiV2Loader { | |
static async search(word, params) { | |
initializeCrossDomainGate(); | |
const query = new NicoSearchApiV2Query(word, params); | |
const url = API_BASE_URL + '?' + query.toString(); | |
return gate.fetch(url).then(res => res.text()).then(result => { | |
result = NicoSearchApiV2Loader.parseResult(result); | |
if (typeof result !== 'number' && result.status === 'ok') { | |
return Promise.resolve(Object.assign(result, {word, params})); | |
} else { | |
let description; | |
switch (result) { | |
default: | |
description = 'UNKNOWN ERROR'; | |
break; | |
case 400: | |
description = 'INVALID QUERY'; | |
break; | |
case 500: | |
description = 'INTERNAL SERVER ERROR'; | |
break; | |
case 503: | |
description = 'MAINTENANCE'; | |
break; | |
} | |
return Promise.reject({ | |
status: 'fail', | |
description | |
}); | |
} | |
}); | |
} | |
static async searchMore(word, params, maxLimit = 300) { | |
const ONCE_LIMIT = 100; // 一回で取れる件数 | |
const PER_PAGE = 25; // 検索ページで1ページあたりに表示される件数 | |
const MAX_PAGE = 64; // 25 * 64 = 1600 | |
const result = await NicoSearchApiV2Loader.search(word, params); | |
const currentPage = params.page ? parseInt(params.page, 10) : 1; | |
const currentOffset = (currentPage - 1) * PER_PAGE; | |
if (result.count <= ONCE_LIMIT) { | |
return result; | |
} | |
const searchCount = Math.min( | |
Math.ceil((result.count - currentOffset) / PER_PAGE) - 1, | |
Math.ceil((maxLimit - ONCE_LIMIT) / ONCE_LIMIT) | |
); | |
for (let i = 1; i <= searchCount; i++) { | |
await sleep(300 * i); | |
let page = currentPage + i * (ONCE_LIMIT / PER_PAGE); | |
console.log('searchNext: "%s"', word, page, params); | |
let res = await NicoSearchApiV2Loader.search(word, Object.assign(params, {page})); | |
if (res && res.list && res.list.length) { | |
result.list = result.list.concat(res.list); | |
} else { | |
break; | |
} | |
} | |
return Object.assign(result, {word, params}); | |
} | |
static _jsonParse(result) { | |
try { | |
return JSON.parse(result); | |
} catch (e) { | |
window.console.error('JSON parse error', e); | |
return null; | |
} | |
} | |
static parseResult(jsonText) { | |
const data = NicoSearchApiV2Loader._jsonParse(jsonText); | |
if (!data) { | |
return 0; | |
} | |
const status = data.meta.status; | |
const result = { | |
status: status === 200 ? 'ok' : 'fail', | |
count: data.meta.totalCount, | |
list: [] | |
}; | |
if (status !== 200) { | |
return status; | |
} | |
const midThumbnailThreshold = 23608629; // .Mのついた最小ID? | |
data.data.forEach(item => { | |
let description = item.description ? item.description.replace(/<.*?>/g, '') : ''; | |
if (item.thumbnailUrl.indexOf('.M') >= 0) { | |
item.thumbnail_url = item.thumbnail_url.replace(/\.M$/, ''); | |
item.is_middle_thumbnail = true; | |
} else if (item.thumbnailUrl.indexOf('.M') < 0 && | |
item.contentId.indexOf('sm') === 0) { | |
let _id = parseInt(item.contentId.substring(2), 10); | |
if (_id >= midThumbnailThreshold) { | |
item.is_middle_thumbnail = true; | |
} | |
} | |
const dt = textUtil.dateToString(new Date(item.startTime)); | |
result.list.push({ | |
id: item.contentId, | |
type: 0, // 0 = VIDEO, | |
length: item.lengthSeconds ? | |
Math.floor(item.lengthSeconds / 60) + ':' + | |
(item.lengthSeconds % 60 + 100).toString().substring(1) : '', | |
mylist_counter: item.mylistCounter, | |
view_counter: item.viewCounter, | |
num_res: item.commentCounter, | |
first_retrieve: dt, | |
create_time: dt, | |
thumbnail_url: item.thumbnailUrl, | |
title: item.title, | |
description_short: description.substring(0, 150), | |
description_full: description, | |
length_seconds: item.lengthSeconds, | |
is_middle_thumbnail: item.is_middle_thumbnail | |
}); | |
}); | |
return result; | |
} | |
} | |
return {NicoSearchApiV2Query, NicoSearchApiV2Loader}; | |
})(); | |
class TagEditApi { | |
load(videoId) { | |
const url = `/tag_edit/${videoId}/?res_type=json&cmd=tags&_=${Date.now()}`; | |
return this._fetch(url, {credentials: 'include'}); | |
} | |
add({videoId, tag, csrfToken, watchAuthKey, ownerLock = 0}) { | |
const url = `/tag_edit/${videoId}/`; | |
const body = this._buildQuery({ | |
cmd: 'add', | |
tag, | |
id: '', | |
token: csrfToken, | |
watch_auth_key: watchAuthKey, | |
owner_lock: ownerLock, | |
res_type: 'json' | |
}); | |
const options = { | |
method: 'POST', | |
credentials: 'include', | |
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, | |
body | |
}; | |
return this._fetch(url, options); | |
} | |
remove({videoId, tag = '', id, csrfToken, watchAuthKey, ownerLock = 0}) { | |
const url = `/tag_edit/${videoId}/`; | |
const body = this._buildQuery({ | |
cmd: 'remove', | |
tag, // いらないかも | |
id, | |
token: csrfToken, | |
watch_auth_key: watchAuthKey, | |
owner_lock: ownerLock, | |
res_type: 'json' | |
}); | |
const options = { | |
method: 'POST', | |
credentials: 'include', | |
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, | |
body | |
}; | |
return this._fetch(url, options); | |
} | |
_buildQuery(params) { | |
const t = []; | |
Object.keys(params).forEach(key => { | |
t.push(`${key}=${encodeURIComponent(params[key])}`); | |
}); | |
return t.join('&'); | |
} | |
_fetch(url, options) { | |
return util.fetch(url, options).then(result => { | |
return result.json(); | |
}); | |
} | |
} | |
Object.assign(ZenzaWatch.api, { | |
VideoInfoLoader, | |
ThumbInfoLoader, | |
MylistApiLoader, | |
UploadedVideoApiLoader, | |
CacheStorage, | |
IchibaLoader, | |
UaaLoader, | |
PlaybackPosition, | |
NicoVideoApi, | |
RecommendAPILoader, | |
NVWatchCaller, | |
CommonsTreeLoader, | |
NicoRssLoader, | |
MatrixRankingLoader, | |
NicoSearchApiV2Loader | |
}); | |
ZenzaWatch.init.mylistApiLoader = MylistApiLoader; | |
ZenzaWatch.init.UploadedVideoApiLoader = UploadedVideoApiLoader; | |
/* | |
* アニメーション基準用の時間ゲッターとしてはperformance.now()よりWeb Animations APIのほうが優れている。 | |
*/ | |
class MediaTimeline { | |
constructor(options = {}) { | |
this.buffer = new (MediaTimeline.isSharable ? self.SharedArrayBuffer : ArrayBuffer)(Float32Array.BYTES_PER_ELEMENT * 100); | |
this.fview = new Float32Array(this.buffer); | |
this.iview = new Int32Array(this.buffer); | |
const span = document.createElement('span'); | |
this.anime = span.animate ? | |
span.animate([], {duration: 3 * 24 * 60 * 60 * 1000}) : | |
{currentTime: 0, playbackRate: 1, paused: true}; | |
this.isWAAvailable = !!span.animate; | |
this.interval = options.interval || 200; | |
this.onTimer = this.onTimer.bind(this); | |
this.onRaf = this.onRaf.bind(this); | |
this.eventMap = this.initEventMap(); | |
this._isBusy = false; | |
if (options.media) { | |
this.attach(options.media); | |
} | |
} | |
initEventMap() { | |
const map = { | |
'pause': e => { | |
this.paused = true; | |
this.currentTime = this.media.currentTime; | |
}, | |
'play': e => { | |
this.currentTime = this.media.currentTime; | |
this.paused = false; | |
}, | |
'seeked': e => { | |
this.currentTime = this.media.currentTime; | |
}, | |
'ratechange': e => { | |
this.playbackRate = this.media.playbackRate; | |
this.currentTime = this.media.currentTime; | |
} | |
}; | |
return objUtil.toMap(map); | |
} | |
attach(media) { | |
if (this.media) { | |
this.detach(); | |
} | |
this.media = media; | |
this.currentTime = media.currentTime; | |
this.playbackRate = media.playbackRate; | |
this.duration = media.duration; | |
this.paused = media.paused; | |
this.timer = setInterval(this.onTimer, this.interval); | |
for (const [eventName, handler] of this.eventMap) { | |
media.addEventListener(eventName, handler, {passive: true}); | |
} | |
} | |
detach() { | |
const media = this.media; | |
for (const [eventName, handler] of this.eventMap) { | |
media.removeEventListener(eventName, handler); | |
} | |
this.media = null; | |
clearInterval(this.timer); | |
} | |
onTimer() { | |
const media = this.media; | |
const ac = this.anime.currentTime / 1000; | |
const mc = media.currentTime; | |
const diffMs = Math.abs(mc - ac) * 1000; | |
if (!this.isWAAvailable || diffMs >= this.interval * 3 || media.paused !== this.paused) { | |
this.currentTime = mc; | |
this.playbackRate = media.playbackRate; | |
this.paused = media.paused; | |
} | |
} | |
onRaf() { | |
if (this._isBusy) { | |
this.raf = null; | |
return; | |
} | |
this._isBusy = true; | |
this.currentTime = Math.min(this.anime.currentTime / 1000, this.media.duration); | |
this.timestamp = Math.round(performance.now() * 1000); | |
if (!this.media.paused) { | |
this.callRaf(); | |
} else { | |
this.raf = null; | |
this._isBusy = false; | |
} | |
} | |
async callRaf() { | |
await sleep.resolve; | |
this.raf = requestAnimationFrame(this.onRaf); | |
this._isBusy = false; | |
} | |
get timestamp() { | |
return this.iview[MediaTimeline.MAP.timestamp]; | |
} | |
set timestamp(v) { | |
if (this.iview[MediaTimeline.MAP.timestamp] === v) { return; } | |
if (MediaTimeline.isSharable) { | |
Atomics.store(this.iview, MediaTimeline.MAP.timestamp, v); | |
Atomics.notify(this.iview, MediaTimeline.MAP.timestamp); | |
} else { | |
this.iview[MediaTimeline.MAP.timestamp] = v; | |
} | |
} | |
get currentTime() { | |
return this.fview[MediaTimeline.MAP.currentTime]; | |
} | |
set currentTime(v) { | |
v = isNaN(v) ? 0 : v; | |
if (this.fview[MediaTimeline.MAP.currentTime] !== v) { | |
this.fview[MediaTimeline.MAP.currentTime] = v; | |
} | |
const ac = this.anime.currentTime / 1000; | |
const diffMs = Math.abs(ac - v) * 1000; | |
if (v === 0 || diffMs > 1000) { | |
this.anime.currentTime = v * 1000; | |
} | |
} | |
get duration() { | |
return this.fview[MediaTimeline.MAP.duration]; | |
} | |
set duration(v) { | |
this.fview[MediaTimeline.MAP.duration] = v; | |
} | |
get playbackRate() { | |
return this.fview[MediaTimeline.MAP.playbackRate]; | |
} | |
set playbackRate(v) { | |
this.fview[MediaTimeline.MAP.playbackRate] = v; | |
(this.anime.playbackRate !== v) && (this.anime.playbackRate = v); | |
} | |
get paused() { | |
return this.iview[MediaTimeline.MAP.paused] !== 0; | |
} | |
set paused(v) { | |
this.iview[MediaTimeline.MAP.paused] = v ? 1 : 0; | |
if (!this.isWAAvailable) { return; } | |
if (v) { | |
this.anime.pause(); | |
this.raf = cancelAnimationFrame(this.raf); | |
this.timestamp = 0; | |
} else { | |
this.anime.play(); | |
if (!this.raf) { | |
this.raf = requestAnimationFrame(this.onRaf); | |
} | |
} | |
} | |
} | |
MediaTimeline.MAP = { | |
currentTime: 0, | |
duration: 1, | |
playbackRate: 2, | |
paused: 3, | |
timestamp: 10 | |
}; | |
MediaTimeline.isSharable = ('SharedArrayBuffer' in self) && ('animate' in document.documentElement); | |
MediaTimeline.register = function(name = 'main', media = null) { | |
if (!this.map.has(name)) { | |
const mt = new MediaTimeline({media}); | |
this.map.set(name, mt); | |
return mt; | |
} | |
const mt = this.map.get(name); | |
media && mt.attach(media); | |
return mt; | |
}.bind({map: new Map()}); | |
MediaTimeline.get = name => MediaTimeline.register(name); | |
WatchInfoCacheDb.api(NicoVideoApi); | |
StoryboardCacheDb.api(NicoVideoApi); | |
const SmileStoryboardInfoLoader = (()=> { | |
let parseStoryboard = ($storyboard, url) => { | |
let id = $storyboard.attr('id') || '1'; | |
return { | |
id, | |
url: url.replace('sb=1', `sb=${id}`), | |
thumbnail: { | |
width: $storyboard.find('thumbnail_width').text(), | |
height: $storyboard.find('thumbnail_height').text(), | |
number: $storyboard.find('thumbnail_number').text(), | |
interval: $storyboard.find('thumbnail_interval').text() | |
}, | |
board: { | |
rows: $storyboard.find('board_rows').text(), | |
cols: $storyboard.find('board_cols').text(), | |
number: $storyboard.find('board_number').text() | |
} | |
}; | |
}; | |
let parseXml = (xml, url) => { | |
let $xml = util.$.html(xml), $storyboard = $xml.find('storyboard'); | |
if ($storyboard.length < 1) { | |
return null; | |
} | |
let info = { | |
format: 'smile', | |
status: 'ok', | |
message: '成功', | |
url, | |
movieId: $xml.find('movie').attr('id'), | |
duration: $xml.find('duration').text(), | |
storyboard: [] | |
}; | |
for (let i = 0, len = $storyboard.length; i < len; i++) { | |
let sbInfo = parseStoryboard(util.$($storyboard[i]), url); | |
info.storyboard.push(sbInfo); | |
} | |
info.storyboard.sort((a, b) => { | |
let idA = parseInt(a.id.substr(1), 10), idB = parseInt(b.id.substr(1), 10); | |
return (idA < idB) ? 1 : -1; | |
}); | |
return info; | |
}; | |
let load = videoFileUrl => { | |
let a = document.createElement('a'); | |
a.href = videoFileUrl; | |
let server = a.host; | |
let search = a.search; | |
if (!/\?(.)=(\d+)\.(\d+)/.test(search)) { | |
return Promise.reject({status: 'fail', message: 'invalid url', url: videoFileUrl}); | |
} | |
let fileType = RegExp.$1; | |
let fileId = RegExp.$2; | |
let key = RegExp.$3; | |
if (fileType !== 'm') { | |
return Promise.reject({status: 'fail', message: 'unknown file type', url: videoFileUrl}); | |
} | |
return new Promise((resolve, reject) => { | |
let url = '//' + server + '/smile?m=' + fileId + '.' + key + '&sb=1'; | |
util.fetch(url, {credentials: 'include'}) | |
.then(res => res.text()) | |
.then(result => { | |
const info = parseXml(result, url); | |
if (info) { | |
resolve(info); | |
} else { | |
reject({ | |
status: 'fail', | |
message: 'storyboard not exist (1)', | |
result: result, | |
url: url | |
}); | |
} | |
}).catch(err => { | |
reject({ | |
status: 'fail', | |
message: 'storyboard not exist (2)', | |
result: err, | |
url: url | |
}); | |
}); | |
}); | |
}; | |
return {load}; | |
})(); | |
const StoryboardInfoLoader = { | |
load: videoInfo => { | |
if (!videoInfo.hasDmcStoryboard) { | |
const url = videoInfo.storyboardUrl; | |
return url ? | |
StoryboardInfoLoader.load(url) : | |
Promise.reject('smile storyboard api not exist'); | |
} | |
const watchId = videoInfo.watchId; | |
const info = videoInfo.dmcStoryboardInfo; | |
const duration = videoInfo.duration; | |
return VideoSessionWorker.storyboard(watchId, info, duration); | |
} | |
}; | |
// ZenzaWatch.api.DmcStoryboardInfoLoader = DmcStoryboardInfoLoader; | |
ZenzaWatch.api.StoryboardInfoLoader = StoryboardInfoLoader; | |
const {ThreadLoader} = (() => { | |
const VERSION_OLD = '20061206'; | |
const VERSION = '20090904'; | |
const FRONT_ID = '6'; | |
const FRONT_VER = '0'; | |
const LANG_CODE = { | |
'en_us': 1, | |
'zh_tw': 2 | |
}; | |
class ThreadLoader { | |
constructor() { | |
this._threadKeys = {}; | |
} | |
getRequestCountByDuration(duration) { | |
if (duration < 60) { return 100; } | |
if (duration < 240) { return 200; } | |
if (duration < 300) { return 400; } | |
return 1000; | |
} | |
getThreadKey(threadId, language = '', options = {}) { | |
let url = `//flapi.nicovideo.jp/api/getthreadkey?thread=${threadId}`; | |
let langCode = this.getLangCode(language); | |
if (langCode) { url = `${url}&language_id=${langCode}`; } | |
const headers = options.cookie ? {Cookie: options.cookie} : {}; | |
return netUtil.fetch(url, { | |
method: 'POST', | |
dataType: 'text', | |
headers, | |
credentials: 'include' | |
}).then(res => res.text()).then(e => { | |
const result = textUtil.parseQuery(e); | |
this._threadKeys[threadId] = result; | |
return result; | |
}).catch(result => { | |
return Promise.reject({ | |
result: result, | |
message: `ThreadKeyの取得失敗 ${threadId}` | |
}); | |
}); | |
} | |
getLangCode(language = '') { | |
language = language.replace('-', '_').toLowerCase(); | |
if (LANG_CODE[language]) { | |
return LANG_CODE[language]; | |
} | |
return 0; | |
} | |
getPostKey(threadId, blockNo, options = {}) { | |
const url = | |
`//flapi.nicovideo.jp/api/getpostkey?device=1&thread=${threadId}&block_no=${blockNo}&version=1&version_sub=2&yugi=`; | |
console.log('getPostkey url: ', url); | |
const headers = options.cookie ? {Cookie: options.cookie} : {}; | |
return netUtil.fetch(url, { | |
method: 'POST', | |
dataType: 'text', | |
headers, | |
credentials: 'include' | |
}).then(res => res.text()).then(e => textUtil.parseQuery(e)).catch(result => { | |
return Promise.reject({ | |
result, | |
message: `PostKeyの取得失敗 ${threadId}` | |
}); | |
}); | |
} | |
buildPacketData(msgInfo, options = {}) { | |
const packets = []; | |
const resCount = this.getRequestCountByDuration(msgInfo.duration); | |
const leafContent = `0-${Math.floor(msgInfo.duration / 60) + 1}:100,${resCount},nicoru:100`; | |
const language = this.getLangCode(msgInfo.language); | |
msgInfo.threads.forEach(thread => { | |
if (!thread.isActive) { return; } | |
const t = { | |
thread: thread.id.toString(), | |
user_id: msgInfo.userId > 0 ? msgInfo.userId.toString() : '', // 0の時は空文字 | |
language, | |
nicoru: 3, | |
scores: 1 | |
}; | |
if (thread.isThreadkeyRequired) { | |
t.threadkey = msgInfo.threadKey[thread.id].key; | |
t.force_184 = msgInfo.threadKey[thread.id].force184 ? '1' : '0'; | |
} | |
if (msgInfo.when > 0) { | |
t.when = msgInfo.when; | |
} | |
if (thread.fork) { | |
t.fork = thread.fork; | |
} | |
if (options.resFrom > 0) { | |
t.res_from = options.resFrom; | |
} | |
if (!t.threadkey /*&& !t.waybackkey*/ && msgInfo.userKey) { | |
t.userkey = msgInfo.userKey; | |
} | |
if (t.fork || thread.isLeafRequired === false) { // 投稿者コメントなど | |
packets.push({thread: Object.assign({with_global: 1, version: VERSION_OLD, res_from: -1000}, t)}); | |
} else { | |
packets.push({thread: Object.assign({with_global: 1, version: VERSION}, t)}); | |
packets.push({thread_leaves: Object.assign({content: leafContent}, t)}); | |
} | |
}); | |
return packets; | |
} | |
buildPacket(msgInfo, options = {}) { | |
const data = this.buildPacketData(msgInfo); | |
if (options.format !== 'xml') { | |
return JSON.stringify(data); | |
} | |
const packet = document.createElement('packet'); | |
data.forEach(d => { | |
const t = document.createElement(d.thread ? 'thread' : 'thread_leaves'); | |
const thread = d.thread ? d.thread : d.thread_leaves; | |
Object.keys(thread).forEach(attr => { | |
if (attr === 'content') { | |
t.textContent = thread[attr]; | |
return; | |
} | |
t.setAttribute(attr, thread[attr]); | |
}); | |
packet.append(t); | |
}); | |
return packet.outerHTML; | |
} | |
_post(server, body, options = {}) { | |
const url = server; | |
return netUtil.fetch(url, { | |
method: 'POST', | |
dataType: 'text', | |
headers: {'Content-Type': 'text/plain; charset=UTF-8'}, | |
body | |
}).then(res => { | |
if (options.format !== 'xml') { | |
return res.json(); | |
} | |
return res.text().then(text => { | |
if (DOMParser) { | |
return new DOMParser().parseFromString(text, 'application/xml'); | |
} | |
return (new JSDOM(text)).window.document; | |
}); | |
}).catch(result => { | |
return Promise.reject({ | |
result, | |
message: `コメントの通信失敗 server: ${server}` | |
}); | |
}); | |
} | |
_load(msgInfo, options = {}) { | |
let packet; | |
const language = msgInfo.language; | |
msgInfo.threadKey = msgInfo.threadKey || {}; | |
const loadThreadKey = threadId => { | |
if (msgInfo.threadKey[threadId]) { return; } | |
msgInfo.threadKey[threadId] = {}; | |
return this.getThreadKey(threadId, language, options).then(info => { | |
console.log('threadKey: ', threadId, info); | |
msgInfo.threadKey[threadId] = {key: info.threadkey, force184: info.force_184}; | |
}); | |
}; | |
const loadThreadKeys = () => | |
Promise.all(msgInfo.threads.filter(t => t.isThreadkeyRequired).map(t => loadThreadKey(t.id))); | |
return Promise.all([loadThreadKeys()]).then(() => { | |
const format = options.format === 'xml' ? 'xml' : 'json'; | |
let server = format === 'json' ? msgInfo.server.replace('/api/', '/api.json/') : msgInfo.server; | |
server = server.replace(/^http:/, ''); | |
packet = this.buildPacket(msgInfo, format); | |
console.log('post packet...', server, packet); | |
return this._post(server, packet, format); | |
}); | |
} | |
load(msgInfo, options = {}) { | |
const server = msgInfo.server; | |
const threadId = msgInfo.threadId; | |
const userId = msgInfo.userId; | |
const timeKey = `loadComment server: ${server} thread: ${threadId}`; | |
console.time(timeKey); | |
const onSuccess = result => { | |
console.timeEnd(timeKey); | |
debug.lastMessageServerResult = result; | |
const format = 'array'; | |
let thread, totalResCount = 0; | |
let resultCode = null; | |
try { | |
let threads = result.filter(t => t.thread).map(t => t.thread); | |
let lastId = null; | |
Array.from(threads).forEach(t => { | |
let id = parseInt(t.thread, 10); | |
let fork = t.fork || 0; | |
if (lastId === id || fork) { | |
return; | |
} | |
lastId = id; | |
msgInfo[id] = thread; | |
if (parseInt(id, 10) === parseInt(threadId, 10)) { | |
thread = t; | |
resultCode = t.resultcode; | |
} | |
if (!isNaN(t.last_res) && !fork) { // 投稿者コメントはカウントしない | |
totalResCount += t.last_res; | |
} | |
}); | |
} catch (e) { | |
console.error(e); | |
} | |
if (resultCode !== 0) { | |
console.log('comment fail:\n', result); | |
return Promise.reject({ | |
message: `コメント取得失敗[${resultCode}]` | |
}); | |
} | |
const last_res = isNaN(thread.last_res) ? 0 : thread.last_res * 1; | |
const threadInfo = { | |
server, | |
userId, | |
resultCode, | |
threadId, | |
thread: thread.thread, | |
serverTime: thread.server_time, | |
force184: msgInfo.defaultThread.isThreadkeyRequired ? '1' : '0', | |
lastRes: last_res, | |
totalResCount, | |
blockNo: Math.floor((last_res + 1) / 100), | |
ticket: thread.ticket || '0', | |
revision: thread.revision, | |
language: msgInfo.language, | |
when: msgInfo.when, | |
isWaybackMode: !!msgInfo.when | |
}; | |
msgInfo.threadInfo = threadInfo; | |
console.log('threadInfo: ', threadInfo); | |
return Promise.resolve({resultCode, threadInfo, body: result, format}); | |
}; | |
const onFailFinally = e => { | |
console.timeEnd(timeKey); | |
window.console.error('loadComment fail finally: ', e); | |
return Promise.reject({ | |
message: 'コメントサーバーの通信失敗: ' + server | |
}); | |
}; | |
const onFail1st = e => { | |
console.timeEnd(timeKey); | |
window.console.error('loadComment fail 1st: ', e); | |
PopupMessage.alert('コメントの取得失敗: 3秒後にリトライ'); | |
return sleep(3000).then(() => this._load(msgInfo, options).then(onSuccess).catch(onFailFinally)); | |
}; | |
return this._load(msgInfo, options).then(onSuccess).catch(onFail1st); | |
} | |
async _postChat(threadInfo, postkey, text, cmd, vpos) { | |
const packet = JSON.stringify([{chat: { | |
content: text, | |
mail: cmd || '', | |
vpos: vpos || 0, | |
premium: util.isPremium() ? 1 : 0, | |
postkey, | |
user_id: threadInfo.userId.toString(), | |
ticket: threadInfo.ticket, | |
thread: threadInfo.threadId.toString() | |
}}]); | |
console.log('post packet: ', packet); | |
const server = threadInfo.server.replace('/api/', '/api.json/'); | |
const result = await this._post(server, packet, 'json'); | |
let status = null, chat_result, no = 0, blockNo = 0; | |
try { | |
chat_result = result.find(t => t.chat_result).chat_result; | |
status = chat_result.status * 1; | |
no = parseInt(chat_result.no, 10); | |
blockNo = Math.floor((no + 1) / 100); | |
} catch (e) { | |
console.error(e); | |
} | |
if (status === 0) { | |
return { | |
status: 'ok', | |
no, | |
blockNo, | |
code: status, | |
message: 'コメント投稿成功' | |
}; | |
} | |
return Promise.reject({ | |
status: 'fail', | |
no, | |
blockNo, | |
code: status, | |
message: `コメント投稿失敗 status: ${status} server: ${threadInfo.server}` | |
}); | |
} | |
async postChat(msgInfo, text, cmd, vpos, lang) { | |
const threadInfo = msgInfo.threadInfo; | |
const tk = await this.getPostKey(threadInfo.threadId, threadInfo.blockNo, lang); | |
const postkey = tk.postkey; | |
let result = await this._postChat(threadInfo, postkey, text, cmd, vpos, lang).catch(r => r); | |
if (result.status === 'ok') { | |
return result; | |
} | |
const errorCode = parseInt(result.code, 10); | |
if (errorCode === 3) { // ticket fail | |
await this.load(msgInfo); | |
} else if (![2, 4, 5].includes(errorCode)) { // リカバー不能系 | |
return Promise.reject(result); | |
} | |
await sleep(3000); | |
result = await this._postChat(threadInfo, postkey, text, cmd, vpos, lang).catch(r => r); | |
return result.status === 'ok' ? result : Promise.reject(result); | |
} | |
getNicoruKey(threadId, langCode = 0, options = {}) { | |
const url = | |
`https://nvapi.nicovideo.jp/v1/nicorukey?language=${langCode}&threadId=${threadId}`; | |
console.log('getNicorukey url: ', url); | |
const headers = options.cookie ? {Cookie: options.cookie} : {}; | |
Object.assign(headers, { | |
'X-Frontend-Id': FRONT_ID, | |
}); | |
return netUtil.fetch(url, { | |
headers, | |
credentials: 'include' | |
}).then(res => res.json()) | |
.then(js => { | |
if (js.meta.status === 200) { | |
return js.data; | |
} | |
return Promise.reject({status: js.meta.status}); | |
}).catch(result => { | |
return Promise.reject({ | |
result, | |
message: `NicoruKeyの取得失敗 ${threadId}` | |
}); | |
}); | |
} | |
async nicoru(msgInfo, chat) { | |
const threadInfo = msgInfo.threadInfo; | |
const language = this.getLangCode(msgInfo.language); | |
const {nicorukey} = await this.getNicoruKey(chat.threadId, language); | |
const server = threadInfo.server.replace('/api/', '/api.json/'); | |
const body = JSON.stringify({nicoru:{ | |
content: chat.text, | |
fork: chat.fork || 0, | |
id: chat.no.toString(), | |
language, | |
nicorukey, | |
postdate: `${chat.date}.${chat.dateUsec}`, | |
premium: nicoUtil.isPremium() ? 1 : 0, | |
thread: chat.threadId.toString(), | |
user_id: msgInfo.userId.toString() | |
}}); | |
const result = await this._post(server, body); | |
const [{nicoru_result: {status}}] = result; | |
if (status === 4) { | |
return Promise.reject({status, message: 'ニコり済みだった'}); | |
} else if (status !== 0) { | |
return Promise.reject({status, message: `ニコれなかった>< (status:${status})`}); | |
} | |
return result; | |
} | |
} | |
return {ThreadLoader: new ThreadLoader}; | |
})(); | |
const {YouTubeWrapper} = (() => { | |
const STATE_PLAYING = 1; | |
class YouTubeWrapper extends Emitter { | |
constructor({parentNode, autoplay = true, volume = 0.3, playbackRate = 1, loop = false}) { | |
super(); | |
this._isInitialized = false; | |
this._parentNode = parentNode; | |
this._autoplay = autoplay; | |
this._volume = volume; | |
this._playbackRate = playbackRate; | |
this._loop = loop; | |
this._startDiff = 0; | |
this._isSeeking = false; | |
this._seekTime = 0; | |
this._onSeekEnd = _.debounce(this._onSeekEnd.bind(this), 500); | |
} | |
async setSrc(url, startSeconds = 0) { | |
this._src = url; | |
this._videoId = this._parseVideoId(url); | |
this._canPlay = false; | |
this._isSeeking = false; | |
this._seekTime = 0; | |
const player = this._player; | |
const isFirst = !!player ? false : true; | |
const urlParams = this._parseUrlParams(url); | |
this._startDiff = /[0-9]+s/.test(urlParams.t) ? parseInt(urlParams.t) : 0; | |
startSeconds += this._startDiff; | |
if (isFirst && !url) { | |
return Promise.resolve(); | |
} | |
if (isFirst) { | |
return this._initPlayer(this._videoId, startSeconds);//.then(({player}) => { | |
} | |
if (!url) { | |
player.stopVideo(); | |
return; | |
} | |
player.loadVideoById({ | |
videoId: this._videoId, | |
startSeconds: startSeconds | |
}); | |
player.loadPlaylist({list: [this._videoId]}); | |
} | |
set src(v) { | |
this.setSrc(v); | |
} | |
get src() { | |
return this._src; | |
} | |
_parseVideoId(url) { | |
const videoId = (() => { | |
const a = textUtil.parseUrl(url); | |
if (a.hostname === 'youtu.be') { | |
return a.pathname.substring(1); | |
} else { | |
return textUtil.parseQuery(a.search).v; | |
} | |
})(); | |
if (!videoId) { | |
return videoId; | |
} | |
return videoId | |
.replace(/[?[\]()"'@]/g, '') | |
.replace(/<[a-z0-9]*>/, ''); | |
} | |
_parseUrlParams(url) { | |
const a = textUtil.parseUrl(url); | |
return a.search.startsWith('?') ? textUtil.parseQuery(a.search) : {}; | |
} | |
async _initPlayer(videoId, startSeconds = 0) { | |
if (this._player) { | |
return {player: this._player}; | |
} | |
const YT = await this._initYT(); | |
const {player} = await new Promise(resolve => { | |
this._player = new YT.Player( | |
this._parentNode, { | |
videoId, | |
events: { | |
onReady: () => resolve({player: this._player}), | |
onStateChange: this._onPlayerStateChange.bind(this), | |
onPlaybackQualityChange: e => window.console.info('video quality: ', e.data), | |
onError: e => this.emit('error', e) | |
}, | |
playerVars: { | |
autoplay: this.autoplay ? 0 : 1, | |
volume: this._volume * 100, | |
start: startSeconds, | |
fs: 0, | |
loop: 0, | |
controls: 1, | |
disablekb: 1, | |
modestbranding: 0, | |
playsinline: 1, | |
rel: 0, | |
showInfo: 1 | |
} | |
}); | |
}); | |
this._onPlayerReady(); | |
} | |
async _initYT() { | |
if (window.YT) { | |
return window.YT; | |
} | |
return new Promise(resolve => { | |
if (window.onYouTubeIframeAPIReady) { | |
window.onYouTubeIframeAPIReady_ = window.onYouTubeIframeAPIReady; | |
} | |
window.onYouTubeIframeAPIReady = () => { | |
if (window.onYouTubeIframeAPIReady_) { | |
window.onYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady_; | |
} | |
resolve(window.YT); | |
}; | |
const tag = document.createElement('script'); | |
tag.src = 'https://www.youtube.com/iframe_api'; | |
document.head.append(tag); | |
}); | |
} | |
_onPlayerReady() { | |
this.emitAsync('loadedMetaData'); | |
} | |
_onPlayerStateChange(e) { | |
const state = e.data; | |
this.playerState = state; | |
const YT = window.YT; | |
switch (state) { | |
case YT.PlayerState.ENDED: | |
if (this._loop) { | |
this.currentTime = 0; | |
this.play(); | |
} else { | |
this.emit('ended'); | |
} | |
break; | |
case YT.PlayerState.PLAYING: | |
if (!this._canPlay) { | |
this._canPlay = true; | |
this.muted = this._muted; | |
this.emit('loadedmetadata'); | |
this.emit('canplay'); | |
} | |
this.emit('play'); | |
this.emit('playing'); | |
if (this._isSeeking) { | |
this.emit('seeked'); | |
} | |
break; | |
case YT.PlayerState.PAUSED: | |
this.emit('pause'); | |
break; | |
case YT.PlayerState.BUFFERING: | |
break; | |
case YT.PlayerState.CUED: | |
break; | |
} | |
} | |
play() { | |
this._player.playVideo(); | |
return Promise.resolve(); // 互換のため | |
} | |
pause() { | |
this._player.pauseVideo(); | |
} | |
get paused() { | |
return window.YT ? | |
this.playerState !== window.YT.PlayerState.PLAYING : true; | |
} | |
selectBestQuality() { | |
const levels = this._player.getAvailableQualityLevels(); | |
const best = levels[0]; | |
this._player.pauseVideo(); | |
this._player.setPlaybackQuality(best); | |
this._player.playVideo(); | |
window.console.info('bestQuality', {levels, best, current: this._player.getPlaybackQuality()}); | |
} | |
_onSeekEnd() { | |
this._isSeeking = false; | |
this._player.seekTo(this._seekTime + this._startDiff); | |
} | |
set currentTime(v) { | |
this._isSeeking = true; | |
this._seekTime = Math.max(0, Math.min(v, this.duration)); | |
this._onSeekEnd(); | |
this.emit('seeking'); | |
} | |
get currentTime() { | |
const now = performance.now(); | |
if (this._isSeeking) { | |
this._lastTime = now; | |
return this._seekTime; | |
} | |
const state = this._player.getPlayerState(); | |
const currentTime = this._player.getCurrentTime() + this._startDiff; | |
if (state !== STATE_PLAYING || this._lastCurrentTime !== currentTime) { | |
this._lastCurrentTime = currentTime; | |
this._lastTime = now; | |
return currentTime; | |
} | |
const timeDiff = (now - this._lastTime) * this.playbackRate / 1000000; | |
this._lastCurrentTime = Math.min(currentTime, this.duration); | |
return currentTime + timeDiff; | |
} | |
get duration() { | |
return this._player.getDuration() - this._startDiff; | |
} | |
set muted(v) { | |
if (v) { | |
this._player.mute(); | |
} else { | |
this._player.unMute(); | |
} | |
this._muted = !!v; | |
} | |
get muted() { | |
return this._player.isMuted(); | |
} | |
set volume(v) { | |
if (this._volume !== v) { | |
this._volume = v; | |
this._player.setVolume(v * 100); | |
this.emit('volumeChange', v); | |
} | |
} | |
get volume() { | |
return this._volume; | |
} | |
set playbackRate(v) { | |
if (this._playbackRate !== v) { | |
this._playbackRate = v; | |
this._player.setPlaybackRate(v); | |
} | |
} | |
get playbackRate() { | |
return this._playbackRate; | |
} | |
set loop(v) { | |
if (this._loop !== v) { | |
this._loop = v; | |
this._player.setLoop(v); | |
} | |
} | |
get loop() { | |
return this._loop; | |
} | |
get _state() { | |
return this._player.getPlayerState(); | |
} | |
get playing() { | |
return this._state === 1; | |
} | |
get videoWidth() { | |
return 1280; | |
} | |
get videoHeight() { | |
return 720; | |
} | |
getAttribute(k) { | |
return this[k]; | |
} | |
removeAttribute() { | |
} | |
} | |
return {YouTubeWrapper}; | |
})(); | |
global.debug.YouTubeWrapper = YouTubeWrapper; | |
class NicoVideoPlayer extends Emitter { | |
constructor(params) { | |
super(); | |
this.initialize(params); | |
} | |
initialize(params) { | |
let conf = this._playerConfig = params.playerConfig; | |
this._fullscreenNode = params.fullscreenNode; | |
this._state = params.playerState; | |
this._state.onkey('videoInfo', this.setVideoInfo.bind(this)); | |
const playbackRate = conf.props.playbackRate; | |
const onCommand = (command, param) => this.emit('command', command, param); | |
this._videoPlayer = new VideoPlayer({ | |
volume: conf.props.volume, | |
loop: conf.props.loop, | |
mute: conf.props.mute, | |
autoPlay: conf.props.autoPlay, | |
playbackRate, | |
debug: conf.props.debug | |
}); | |
this._videoPlayer.on('command', onCommand); | |
this._commentPlayer = new NicoCommentPlayer({ | |
filter: { | |
enableFilter: conf.props.enableFilter, | |
wordFilter: conf.props.wordFilter, | |
wordRegFilter: conf.props.wordRegFilter, | |
wordRegFilterFlags: conf.props.wordRegFilterFlags, | |
userIdFilter: conf.props.userIdFilter, | |
commandFilter: conf.props.commandFilter, | |
removeNgMatchedUser: conf.props.removeNgMatchedUser, | |
fork0: conf.props['filter.fork0'], | |
fork1: conf.props['filter.fork1'], | |
fork2: conf.props['filter.fork2'], | |
sharedNgLevel: conf.props.sharedNgLevel | |
}, | |
showComment: conf.props.showComment, | |
debug: conf.props.debug, | |
playbackRate, | |
}); | |
this._commentPlayer.on('command', onCommand); | |
this._contextMenu = new ContextMenu({ | |
parentNode: params.node.length ? params.node[0] : params.node, | |
playerState: this._state | |
}); | |
this._contextMenu.on('command', onCommand); | |
if (params.node) { | |
this.appendTo(params.node); | |
} | |
this._initializeEvents(); | |
this._onTimer = this._onTimer.bind(this); | |
this._beginTimer(); | |
global.debug.nicoVideoPlayer = this; | |
} | |
_beginTimer() { | |
this._stopTimer(); | |
this._videoWatchTimer = | |
self.setInterval(this._onTimer, 100); | |
} | |
_stopTimer() { | |
if (!this._videoWatchTimer) { | |
return; | |
} | |
self.clearInterval(this._videoWatchTimer); | |
this._videoWatchTimer = null; | |
} | |
_initializeEvents() { | |
const eventBridge = function(...args) { | |
this.emit(...args); | |
}; | |
this._videoPlayer.on('volumeChange', this._onVolumeChange.bind(this)); | |
this._videoPlayer.on('dblclick', this._onDblClick.bind(this)); | |
this._videoPlayer.on('aspectRatioFix', this._onAspectRatioFix.bind(this)); | |
this._videoPlayer.on('play', this._onPlay.bind(this)); | |
this._videoPlayer.on('playing', this._onPlaying.bind(this)); | |
this._videoPlayer.on('seeking', this._onSeeking.bind(this)); | |
this._videoPlayer.on('seeked', this._onSeeked.bind(this)); | |
this._videoPlayer.on('stalled', eventBridge.bind(this, 'stalled')); | |
this._videoPlayer.on('timeupdate', eventBridge.bind(this, 'timeupdate')); | |
this._videoPlayer.on('waiting', eventBridge.bind(this, 'waiting')); | |
this._videoPlayer.on('progress', eventBridge.bind(this, 'progress')); | |
this._videoPlayer.on('pause', this._onPause.bind(this)); | |
this._videoPlayer.on('ended', this._onEnded.bind(this)); | |
this._videoPlayer.on('loadedMetaData', this._onLoadedMetaData.bind(this)); | |
this._videoPlayer.on('canPlay', this._onVideoCanPlay.bind(this)); | |
this._videoPlayer.on('durationChange', eventBridge.bind(this, 'durationChange')); | |
this._videoPlayer.on('playerTypeChange', eventBridge.bind(this, 'videoPlayerTypeChange')); | |
this._videoPlayer.on('buffercomplete', eventBridge.bind(this, 'buffercomplete')); | |
this._videoPlayer.on('mouseWheel', | |
_.throttle(this._onMouseWheel.bind(this), 50)); | |
this._videoPlayer.on('abort', eventBridge.bind(this, 'abort')); | |
this._videoPlayer.on('error', eventBridge.bind(this, 'error')); | |
this._videoPlayer.on('click', this._onClick.bind(this)); | |
this._videoPlayer.on('contextMenu', this._onContextMenu.bind(this)); | |
this._commentPlayer.on('parsed', eventBridge.bind(this, 'commentParsed')); | |
this._commentPlayer.on('change', eventBridge.bind(this, 'commentChange')); | |
this._commentPlayer.on('filterChange', eventBridge.bind(this, 'commentFilterChange')); | |
this._state.on('update', this._onPlayerStateUpdate.bind(this)); | |
} | |
_onVolumeChange(vol, mute) { | |
this._playerConfig.props.volume = vol; | |
this._playerConfig.props.mute = mute; | |
this.emit('volumeChange', vol, mute); | |
} | |
_onPlayerStateUpdate(key, value) { | |
switch (key) { | |
case 'isLoop': | |
this._videoPlayer.isLoop=value; | |
break; | |
case 'playbackRate': | |
this._videoPlayer.playbackRate=value; | |
this._commentPlayer.playbackRate=value; | |
break; | |
case 'isAutoPlay': | |
this._videoPlayer.isAutoPlay=value; | |
break; | |
case 'isShowComment': | |
if (value) { | |
this._commentPlayer.show(); | |
} else { | |
this._commentPlayer.hide(); | |
} | |
break; | |
case 'isMute': | |
this._videoPlayer.muted = value; | |
break; | |
case 'sharedNgLevel': | |
this.filter.sharedNgLevel = value; | |
break; | |
case 'currentSrc': | |
this.setVideo(value); | |
break; | |
} | |
} | |
_onMouseWheel(e, delta) { | |
if (delta > 0) { // up | |
this.volumeUp(); | |
} else { // down | |
this.volumeDown(); | |
} | |
} | |
volumeUp() { | |
const v = Math.max(0.01, this._videoPlayer.volume); | |
const r = v < 0.05 ? 1.3 : 1.1; | |
this._videoPlayer.volume = Math.max(0, v * r); | |
} | |
volumeDown() { | |
const v = this._videoPlayer.volume; | |
const r = 1 / 1.2; | |
this._videoPlayer.volume = v * r; | |
} | |
_onTimer() { | |
this._commentPlayer.currentTime = this._videoPlayer.currentTime; | |
} | |
_onAspectRatioFix(ratio) { | |
this._commentPlayer.setAspectRatio(ratio); | |
this.emit('aspectRatioFix', ratio); | |
} | |
_onLoadedMetaData() { | |
this.emit('loadedMetaData'); | |
} | |
_onVideoCanPlay() { | |
this.emit('canPlay'); | |
if (this.autoplay && !this.paused) { | |
this._video.play().catch(err => { | |
if (err instanceof DOMException) { | |
if (err.code === 35 /* NotAllowedError */) { | |
this.dispatchEvent(new CustomEvent('autoplay-rejected')); | |
} | |
} | |
}); | |
} | |
} | |
_onPlay() { | |
this._isPlaying = true; | |
this.emit('play'); | |
} | |
_onPlaying() { | |
this._isPlaying = true; | |
this.emit('playing'); | |
} | |
_onSeeking() { | |
this._isSeeking = true; | |
this.emit('seeking'); | |
} | |
_onSeeked() { | |
this._isSeeking = false; | |
this.emit('seeked'); | |
} | |
_onPause() { | |
this._isPlaying = false; | |
this.emit('pause'); | |
} | |
_onEnded() { | |
this._isPlaying = false; | |
this._isEnded = true; | |
this.emit('ended'); | |
} | |
_onClick() { | |
this._contextMenu.hide(); | |
} | |
_onDblClick() { | |
if (this._playerConfig.props.enableFullScreenOnDoubleClick) { | |
this.toggleFullScreen(); | |
} | |
} | |
_onContextMenu(e) { | |
if (!this._contextMenu.isOpen) { | |
e.stopPropagation(); | |
e.preventDefault(); | |
this._contextMenu.show(e.clientX, e.clientY); | |
} | |
} | |
setVideo(url) { | |
let e = {src: url, url: null, promise: null}; | |
global.emitter.emit('beforeSetVideo', e); | |
if (e.url) { | |
url = e.url; | |
} | |
if (e.promise) { | |
return e.promise.then(url => { | |
this._videoPlayer.setSrc(url); | |
this._isEnded = false; | |
}); | |
} | |
this._videoPlayer.setSrc(url); | |
this._isEnded = false; | |
this._isSeeking = false; | |
} | |
setThumbnail(url) { | |
this._videoPlayer.thumbnail = url; | |
} | |
play() { | |
return this._videoPlayer.play(); | |
} | |
pause() { | |
this._videoPlayer.pause(); | |
return Promise.resolve(); | |
} | |
togglePlay() { | |
return this._videoPlayer.togglePlay(); | |
} | |
setPlaybackRate(playbackRate) { | |
playbackRate = Math.max(0, Math.min(playbackRate, 10)); | |
this._videoPlayer.playbackRate = playbackRate; | |
this._commentPlayer.setPlaybackRate(playbackRate); | |
} | |
fastSeek(t) {this._videoPlayer.fastSeek(Math.max(0, t));} | |
set currentTime(t) {this._videoPlayer.currentTime = Math.max(0, t);} | |
get currentTime() { return this._videoPlayer.currentTime;} | |
get vpos() { return this.currentTime * 100; } | |
get duration() {return this._videoPlayer.duration;} | |
get chatList() {return this._commentPlayer.chatList;} | |
get nonFilteredChatList() {return this._commentPlayer.nonFilteredChatList;} | |
appendTo(node) { | |
node = util.$(node)[0]; | |
this._parentNode = node; | |
this._videoPlayer.appendTo(node); | |
this._commentPlayer.appendTo(node); | |
} | |
close() { | |
this._videoPlayer.close(); | |
this._commentPlayer.close(); | |
} | |
closeCommentPlayer() { | |
this._commentPlayer.close(); | |
} | |
toggleFullScreen() { | |
if (Fullscreen.now()) { | |
Fullscreen.cancel(); | |
} else { | |
this.requestFullScreen(); | |
} | |
} | |
requestFullScreen() { | |
Fullscreen.request(this._fullscreenNode || this._parentNode); | |
} | |
canPlay() { | |
return this._videoPlayer.canPlay(); | |
} | |
get isPlaying() { | |
return !!this._isPlaying; | |
} | |
get paused() { | |
return this._videoPlayer.paused; | |
} | |
get isSeeking() { | |
return !!this._isSeeking; | |
} | |
get bufferedRange() {return this._videoPlayer.bufferedRange;} | |
addChat(text, cmd, vpos, options) { | |
if (!this._commentPlayer) { | |
return; | |
} | |
const nicoChat = this._commentPlayer.addChat(text, cmd, vpos, options); | |
console.log('addChat:', text, cmd, vpos, options, nicoChat); | |
return nicoChat; | |
} | |
get filter() {return this._commentPlayer.filter;} | |
get videoInfo() {return this._videoInfo;} | |
set videoInfo(info) {this._videoInfo = info;} | |
getMymemory() {return this._commentPlayer.getMymemory();} | |
getScreenShot() { | |
window.console.time('screenShot'); | |
const fileName = this._getSaveFileName(); | |
const video = this._videoPlayer.videoElement; | |
return VideoCaptureUtil.videoToCanvas(video).then(({canvas}) => { | |
VideoCaptureUtil.saveToFile(canvas, fileName); | |
window.console.timeEnd('screenShot'); | |
}); | |
} | |
getScreenShotWithComment() { | |
window.console.time('screenShotWithComment'); | |
const fileName = this._getSaveFileName({suffix: 'C'}); | |
const video = this._videoPlayer.videoElement; | |
const html = this._commentPlayer.getCurrentScreenHtml(); | |
return VideoCaptureUtil.nicoVideoToCanvas({video, html}).then(({canvas}) => { | |
VideoCaptureUtil.saveToFile(canvas, fileName); | |
window.console.timeEnd('screenShotWithComment'); | |
}); | |
} | |
_getSaveFileName({suffix = ''} = {}) { | |
const title = this._videoInfo.title; | |
const watchId = this._videoInfo.watchId; | |
const currentTime = this._videoPlayer.currentTime; | |
const time = util.secToTime(currentTime).replace(':', '_'); | |
const prefix = Config.props['screenshot.prefix'] || ''; | |
return `${prefix}${title} - ${watchId}@${time}${suffix}.png`; | |
} | |
get isCorsReady() {return this._videoPlayer && this._videoPlayer.isCorsReady;} | |
get volume() { return this._videoPlayer.volume;} | |
set volume(v) {this._videoPlayer.volume = v;} | |
getDuration() {return this._videoPlayer.duration;} | |
getChatList() {return this._commentPlayer.chatList;} | |
getVpos() {return Math.floor(this._videoPlayer.currentTime * 100);} | |
setComment(xmlText, options) {this._commentPlayer.setComment(xmlText, options);} | |
getNonFilteredChatList() {return this._commentPlayer.nonFilteredChatList;} | |
getBufferedRange() {return this._videoPlayer.bufferedRange;} | |
setVideoInfo(v) { this.videoInfo = v; } | |
getVideoInfo() { return this.videoInfo; } | |
} | |
class ContextMenu extends BaseViewComponent { | |
constructor({parentNode, playerState}) { | |
super({ | |
parentNode, | |
name: 'VideoContextMenu', | |
template: ContextMenu.__tpl__, | |
css: ContextMenu.__css__ | |
}); | |
this._playerState = playerState; | |
this._state = { | |
isOpen: false | |
}; | |
this._bound.onBodyClick = this.hide.bind(this); | |
} | |
_initDom(...args) { | |
super._initDom(...args); | |
global.debug.contextMenu = this; | |
const onMouseDown = this._bound.onMouseDown = this._onMouseDown.bind(this); | |
this._bound.onBodyMouseUp = this._onBodyMouseUp.bind(this); | |
this._bound.onRepeat = this._onRepeat.bind(this); | |
this._view.classList.toggle('is-pictureInPictureEnabled', document.pictureInPictureEnabled); | |
this._view.addEventListener('mousedown', onMouseDown); | |
this._isFirstShow = true; | |
this._view.addEventListener('contextmenu', (e) => { | |
setTimeout(() => { | |
this.hide(); | |
}, 100); | |
e.preventDefault(); | |
e.stopPropagation(); | |
}); | |
} | |
_onClick(e) { | |
if (e && e.button !== 0) { | |
return; | |
} | |
if (e.type !== 'mousedown') { | |
e.preventDefault(); | |
e.stopPropagation(); | |
return; | |
} | |
e.stopPropagation(); | |
super._onClick(e); | |
} | |
_onMouseDown(e) { | |
if (e.target && e.target.getAttribute('data-is-no-close') === 'true') { | |
e.stopPropagation(); | |
this._onClick(e); | |
} else if (e.target && e.target.getAttribute('data-repeat') === 'on') { | |
e.stopPropagation(); | |
this._onClick(e); | |
this._beginRepeat(e); | |
} else { | |
e.stopPropagation(); | |
this._onClick(e); | |
setTimeout(() => { | |
this.hide(); | |
}, 100); | |
} | |
} | |
_onBodyMouseUp() { | |
this._endRepeat(); | |
} | |
_beginRepeat(e) { | |
this._repeatEvent = e; | |
document.body.addEventListener('mouseup', this._bound.onBodyMouseUp); | |
this._repeatTimer = window.setInterval(this._bound.onRepeat, 200); | |
this._isRepeating = true; | |
} | |
_endRepeat() { | |
this._repeatEvent = null; | |
if (this._repeatTimer) { | |
window.clearInterval(this._repeatTimer); | |
this._repeatTimer = null; | |
} | |
document.body.removeEventListener('mouseup', this._bound.onBodyMouseUp); | |
} | |
_onRepeat() { | |
if (!this._isRepeating) { | |
this._endRepeat(); | |
return; | |
} | |
if (this._repeatEvent) { | |
this._onClick(this._repeatEvent); | |
} | |
} | |
show(x, y) { | |
document.body.addEventListener('click', this._bound.onBodyClick); | |
const view = this._view; | |
this._onBeforeShow(x, y); | |
view.style.left = | |
cssUtil.px(Math.max(0, Math.min(x, global.innerWidth - view.offsetWidth))); | |
view.style.top = | |
cssUtil.px(Math.max(0, Math.min(y + 20, global.innerHeight - view.offsetHeight))); | |
this.setState({isOpen: true}); | |
global.emitter.emitAsync('showMenu'); | |
} | |
hide() { | |
document.body.removeEventListener('click', this._bound.onBodyClick); | |
util.$(this._view).css({left: '', top: ''}); | |
this._endRepeat(); | |
this.setState({isOpen: false}); | |
global.emitter.emitAsync('hideMenu'); | |
} | |
get isOpen() { | |
return this._state.isOpen; | |
} | |
_onBeforeShow() { | |
const pr = parseFloat(this._playerState.playbackRate, 10); | |
const view = util.$(this._view); | |
view.find('.selected').removeClass('selected'); | |
view.find('.playbackRate').forEach(elm => { | |
const p = parseFloat(elm.dataset.param, 10); | |
if (Math.abs(p - pr) < 0.01) { | |
elm.classList.add('selected'); | |
} | |
}); | |
view.find('[data-config]').forEach(menu => { | |
const name = menu.dataset.config; | |
menu.classList.toggle('selected', !!global.config.props[name]); | |
}); | |
view.find('.seekToResumePoint') | |
.css('display', this._playerState.videoInfo.initialPlaybackTime > 0 ? '' : 'none'); | |
if (this._isFirstShow) { | |
this._isFirstShow = false; | |
const handler = (command, param) => { | |
this.emit('command', command, param); | |
}; | |
global.emitter.emitAsync('videoContextMenu.addonMenuReady', | |
view.find('.empty-area-top'), handler | |
); | |
global.emitter.emitAsync('videoContextMenu.addonMenuReady.list', | |
view.find('.listInner ul'), handler | |
); | |
global.emitter.emitResolve('videoContextMenu.addonMenuReady', | |
{container: view.find('.empty-area-top'), handler} | |
); | |
global.emitter.emitResolve('videoContextMenu.addonMenuReady.list', | |
{container: view.find('.listInner ul'), handler} | |
); | |
} | |
} | |
} | |
ContextMenu.__css__ = (` | |
.zenzaPlayerContextMenu { | |
position: fixed; | |
background: rgba(255, 255, 255, 0.8); | |
overflow: visible; | |
padding: 8px; | |
border: 1px outset #333; | |
box-shadow: 2px 2px 4px #000; | |
transition: opacity 0.3s ease; | |
min-width: 200px; | |
z-index: 150000; | |
user-select: none; | |
color: #000; | |
} | |
.zenzaPlayerContextMenu.is-Open { | |
display: block; | |
opacity: 0.5; | |
} | |
.zenzaPlayerContextMenu.is-Open:hover { | |
opacity: 1; | |
} | |
.is-fullscreen .zenzaPlayerContextMenu { | |
position: absolute; | |
} | |
.zenzaPlayerContextMenu:not(.is-Open) { | |
display: none; | |
/*left: -9999px; | |
top: -9999px; | |
opacity: 0;*/ | |
} | |
.zenzaPlayerContextMenu ul { | |
padding: 0; | |
margin: 0; | |
} | |
.zenzaPlayerContextMenu ul li { | |
position: relative; | |
line-height: 120%; | |
margin: 2px; | |
overflow-y: visible; | |
white-space: nowrap; | |
cursor: pointer; | |
padding: 2px 14px; | |
list-style-type: none; | |
float: inherit; | |
} | |
.is-playlistEnable .zenzaPlayerContextMenu li.togglePlaylist:before, | |
.is-flipV .zenzaPlayerContextMenu li.toggle-flipV:before, | |
.is-flipH .zenzaPlayerContextMenu li.toggle-flipH:before, | |
.zenzaPlayerContextMenu ul li.selected:before { | |
content: '✔'; | |
left: -10px; | |
color: #000 !important; | |
position: absolute; | |
} | |
.zenzaPlayerContextMenu ul li:hover { | |
background: #336; | |
color: #fff; | |
} | |
.zenzaPlayerContextMenu ul li.separator { | |
border: 1px outset; | |
height: 2px; | |
width: 90%; | |
} | |
.zenzaPlayerContextMenu.show { | |
opacity: 0.8; | |
} | |
.zenzaPlayerContextMenu .listInner { | |
} | |
.zenzaPlayerContextMenu .controlButtonContainer { | |
position: absolute; | |
bottom: 100%; | |
left: 50%; | |
width: 110%; | |
transform: translate(-50%, 0); | |
white-space: nowrap; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex { | |
display: flex; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton { | |
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; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton.screenShot { | |
flex: 1; | |
font-size: 24px; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton.playbackRate { | |
flex: 2; | |
font-size: 14px; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton.rate010, | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton.rate100, | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton.rate200 { | |
flex: 3; | |
font-size: 24px; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton.seek5s { | |
flex: 2; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton.seek15s { | |
flex: 3; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton:hover { | |
transform: translate(0px, -4px); | |
box-shadow: 0px 4px 2px #666; | |
} | |
.zenzaPlayerContextMenu .controlButtonContainerFlex > .controlButton:active { | |
transform: none; | |
box-shadow: 0 0 0; | |
border: 1px inset; | |
} | |
[data-command="picture-in-picture"] { | |
display: none; | |
} | |
.is-pictureInPictureEnabled [data-command="picture-in-picture"] { | |
display: block; | |
} | |
`).trim(); | |
ContextMenu.__tpl__ = (` | |
<div class="zenzaPlayerContextMenu"> | |
<div class="controlButtonContainer"> | |
<div class="controlButtonContainerFlex"> | |
<div class="controlButton command screenShot" data-command="screenShot" | |
data-param="0.1" data-type="number" data-is-no-close="true"> | |
📷<div class="tooltip">スクリーンショット</div> | |
</div> | |
<div class="empty-area-top" style="flex:4;" data-is-no-close="true"></div> | |
</div> | |
<div class="controlButtonContainerFlex"> | |
<div class="controlButton command rate010 playbackRate" data-command="playbackRate" | |
data-param="0.1" data-type="number" data-repeat="on"> | |
🐢<div class="tooltip">コマ送り(0.1倍)</div> | |
</div> | |
<div class="controlButton command rate050 playbackRate" data-command="playbackRate" | |
data-param="0.5" data-type="number" data-repeat="on"> | |
<div class="tooltip">0.5倍速</div> | |
</div> | |
<div class="controlButton command rate075 playbackRate" data-command="playbackRate" | |
data-param="0.75" data-type="number" data-repeat="on"> | |
<div class="tooltip">0.75倍速</div> | |
</div> | |
<div class="controlButton command rate100 playbackRate" data-command="playbackRate" | |
data-param="1.0" data-type="number" data-repeat="on"> | |
▷<div class="tooltip">標準速</div> | |
</div> | |
<div class="controlButton command rate125 playbackRate" data-command="playbackRate" | |
data-param="1.25" data-type="number" data-repeat="on"> | |
<div class="tooltip">1.25倍速</div> | |
</div> | |
<div class="controlButton command rate150 playbackRate" data-command="playbackRate" | |
data-param="1.5" data-type="number" data-repeat="on"> | |
<div class="tooltip">1.5倍速</div> | |
</div> | |
<div class="controlButton command rate200 playbackRate" data-command="playbackRate" | |
data-param="2.0" data-type="number" data-repeat="on"> | |
🐇<div class="tooltip">2倍速</div> | |
</div> | |
</div> | |
<div class="controlButtonContainerFlex seekToResumePoint"> | |
<div class="controlButton command" | |
data-command="seekToResumePoint" | |
>▼ここまで見た | |
<div class="tooltip">レジューム位置にジャンプ</div> | |
</div> | |
</div> | |
<div class="controlButtonContainerFlex"> | |
<div class="controlButton command seek5s" | |
data-command="seekBy" data-param="-5" data-type="number" data-repeat="on" | |
>⇦ | |
<div class="tooltip">5秒戻る</div> | |
</div> | |
<div class="controlButton command seek15s" | |
data-command="seekBy" data-param="-15" data-type="number" data-repeat="on" | |
>⇦ | |
<div class="tooltip">15秒戻る</div> | |
</div> | |
<div class="controlButton command seek15s" | |
data-command="seekBy" data-param="15" data-type="number" data-repeat="on" | |
>⇨ | |
<div class="tooltip">15秒進む</div> | |
</div> | |
<div class="controlButton command seek5s" | |
data-command="seekBy" data-param="5" data-type="number" data-repeat="on" | |
>⇨ | |
<div class="tooltip">5秒進む</div> | |
</div> | |
</div> | |
</div> | |
<div class="listInner"> | |
<ul> | |
<li class="command" data-command="togglePlay">停止/再開</li> | |
<li class="command" data-command="seekTo" data-param="0">先頭に戻る</li> | |
<hr class="separator"> | |
<li class="command toggleLoop" data-config="loop" data-command="toggle-loop">リピート</li> | |
<li class="command togglePlaylist" data-command="togglePlaylist">連続再生</li> | |
<li class="command toggleShowComment" data-config="showComment" data-command="toggle-showComment">コメントを表示</li> | |
<li class="command" data-command="picture-in-picture">P in P</li> | |
<hr class="separator"> | |
<li class="command forPremium toggle-flipH" data-command="toggle-flipH">左右反転</li> | |
<li class="command toggle-flipV" data-command="toggle-flipV">上下反転</li> | |
<hr class="separator"> | |
<li class="command" | |
data-command="reload">動画のリロード</li> | |
<li class="command" | |
data-command="copy-video-watch-url">動画URLをコピー</li> | |
<li class="command debug" data-config="debug" | |
data-command="toggle-debug">デバッグ</li> | |
<li class="command mymemory" | |
data-command="saveMymemory">コメントの保存</li> | |
</ul> | |
</div> | |
</div> | |
`).trim(); | |
class VideoPlayer extends Emitter { | |
constructor(params) { | |
super(); | |
this._initialize(params); | |
global.debug.timeline = MediaTimeline.register('main', this); | |
} | |
_initialize(params) { | |
this._id = 'video' + Math.floor(Math.random() * 100000); | |
this._resetVideo(params); | |
util.addStyle(VideoPlayer.__css__); | |
} | |
_reset() { | |
this.removeClass('is-play is-pause is-abort is-error'); | |
this._isPlaying = false; | |
this._canPlay = false; | |
} | |
addClass(className) { | |
this.classList.add(...className.split(/\s/)); | |
} | |
removeClass(className) { | |
this.classList.remove(...className.split(/\s/)); | |
} | |
toggleClass(className, v) { | |
const classList = this.classList; | |
className.split(/[ ]+/).forEach(name => { | |
classList.toggle(name, v); | |
}); | |
} | |
_resetVideo(params) { | |
params = params || {}; | |
if (this._videoElement) { | |
params.autoplay = this._videoElement.autoplay; | |
params.loop = this._videoElement.loop; | |
params.mute = this._videoElement.muted; | |
params.volume = this._videoElement.volume; | |
params.playbackRate = this._videoElement.playbackRate; | |
this._videoElement.remove(); | |
} | |
const options = { | |
autobuffer: true, | |
preload: 'auto', | |
mute: !!params.mute, | |
'playsinline': true, | |
'webkit-playsinline': true | |
}; | |
const volume = | |
params.hasOwnProperty('volume') ? parseFloat(params.volume) : 0.5; | |
const playbackRate = this._playbackRate = | |
params.hasOwnProperty('playbackRate') ? parseFloat(params.playbackRate) : 1.0; | |
const video = util.createVideoElement(); | |
const body = document.createElement('div'); | |
util.$(body) | |
.addClass(`videoPlayer nico ${this._id}`); | |
util.$(video) | |
.addClass('videoPlayer-video') | |
.attr(options); | |
body.id = 'ZenzaWatchVideoPlayerContainer'; | |
this._body = body; | |
this.classList = ClassList(body); | |
body.append(video); | |
video.pause(); | |
this._video = video; | |
this._video.className = 'zenzaWatchVideoElement'; | |
video.controlslist = 'nodownload'; | |
video.controls = false; | |
video.autoplay = !!params.autoPlay; | |
video.loop = !!params.loop; | |
this._videoElement = video; | |
this._isPlaying = false; | |
this._canPlay = false; | |
this.volume = volume; | |
this.muted = params.mute; | |
this.playbackRate = playbackRate; | |
this._touchWrapper = new TouchWrapper({ | |
parentElement: body | |
}); | |
this._touchWrapper.on('command', (command, param) => { | |
if (command === 'contextMenu') { | |
this._emit('contextMenu', param); | |
return; | |
} | |
this.emit('command', command, param); | |
}); | |
this._initializeEvents(); | |
global.debug.video = this._video; | |
Object.assign(global.external, {getVideoElement: () => this._video}); | |
} | |
_initializeEvents() { | |
const eventBridge = function(name, ...args) { | |
console.log('%c_on-%s:', 'background: cyan;', name, ...args); | |
this.emit(name, ...args); | |
}; | |
util.$(this._video) | |
.on('canplay', this._onCanPlay.bind(this)) | |
.on('canplaythrough', eventBridge.bind(this, 'canplaythrough')) | |
.on('loadstart', eventBridge.bind(this, 'loadstart')) | |
.on('loadeddata', eventBridge.bind(this, 'loadeddata')) | |
.on('loadedmetadata', eventBridge.bind(this, 'loadedmetadata')) | |
.on('ended', eventBridge.bind(this, 'ended')) | |
.on('emptied', eventBridge.bind(this, 'emptied')) | |
.on('suspend', eventBridge.bind(this, 'suspend')) | |
.on('waiting', eventBridge.bind(this, 'waiting')) | |
.on('progress', this._onProgress.bind(this)) | |
.on('durationchange', this._onDurationChange.bind(this)) | |
.on('abort', this._onAbort.bind(this)) | |
.on('error', this._onError.bind(this)) | |
.on('buffercomplete', eventBridge.bind(this, 'buffercomplete')) | |
.on('pause', this._onPause.bind(this)) | |
.on('play', this._onPlay.bind(this)) | |
.on('playing', this._onPlaying.bind(this)) | |
.on('seeking', this._onSeeking.bind(this)) | |
.on('seeked', this._onSeeked.bind(this)) | |
.on('volumechange', this._onVolumeChange.bind(this)) | |
.on('contextmenu', eventBridge.bind(this, 'contextmenu')) | |
.on('click', eventBridge.bind(this, 'click')) | |
; | |
const touch = util.$(this._touchWrapper.body); | |
touch | |
.on('click', eventBridge.bind(this, 'click')) | |
.on('dblclick', this._onDoubleClick.bind(this)) | |
.on('contextmenu', eventBridge.bind(this, 'contextmenu')) | |
.on('wheel', this._onMouseWheel.bind(this), {passive: true}) | |
; | |
} | |
_onCanPlay(...args) { | |
console.log('%c_onCanPlay:', 'background: cyan; color: blue;', ...args); | |
this.playbackRate = this.playbackRate; | |
if (!this._canPlay) { | |
this._canPlay = true; | |
this.removeClass('is-loading'); | |
this.emit('canPlay', ...args); | |
if (this._video.videoHeight < 1) { | |
this._isAspectRatioFixed = false; | |
} else { | |
this._isAspectRatioFixed = true; | |
this.emit('aspectRatioFix', | |
this._video.videoHeight / Math.max(1, this._video.videoWidth)); | |
} | |
if (this._isYouTube && Config.props.bestZenTube) { | |
this._videoYouTube.selectBestQuality(); | |
} | |
} | |
} | |
_onProgress() { | |
this.emit('progress', this._video.buffered, this._video.currentTime); | |
} | |
_onDurationChange() { | |
console.log('%c_onDurationChange:', 'background: cyan;', arguments); | |
this.emit('durationChange', this._video.duration); | |
} | |
_onAbort() { | |
if (this._isYouTube) { | |
return; | |
} // TODO: YouTube側のエラーハンドリング | |
this._isPlaying = false; | |
this.addClass('is-abort'); | |
this.emit('abort'); | |
} | |
_onError(e) { | |
if (this._isYouTube) { | |
return; | |
} | |
if (this._videoElement.src === CONSTANT.BLANK_VIDEO_URL || | |
!this._videoElement.src || | |
this._videoElement.src.match(/^https?:$/) || | |
this._videoElement.src === '//' | |
) { | |
return; | |
} | |
window.console.error('error src', this._video.src); | |
window.console.error('%c_onError:', 'background: cyan; color: red;', arguments); | |
this.addClass('is-error'); | |
this._canPlay = false; | |
this.emit('error', { | |
code: (e && e.target && e.target.error && e.target.error.code) || 0, | |
target: e.target || this._video, | |
type: 'normal' | |
}); | |
} | |
_onYouTubeError(e) { | |
window.console.error('error src', this._video.src); | |
window.console.error('%c_onError:', 'background: cyan; color: red;', e); | |
this.addClass('is-error'); | |
this._canPlay = false; | |
let fallback = false; | |
const code = e.data; | |
const description = (() => { | |
switch (code) { | |
case 2: | |
return 'YouTube Error: パラメータエラー (2 invalid parameter)'; | |
case 5: | |
return 'YouTube Error: HTML5 関連エラー (5 HTML5 error)'; | |
case 100: | |
fallback = true; | |
return 'YouTube Error: 動画が見つからないか、非公開 (100 video not found)'; | |
case 101: | |
case 150: | |
fallback = true; | |
return `YouTube Error: 外部での再生禁止 (${code} forbidden)`; | |
default: | |
return `YouTube Error: (code${code})`; | |
} | |
})(); | |
this.emit('error', { | |
code, | |
description, | |
fallback, | |
target: this._videoElement, | |
type: 'youtube' | |
}); | |
} | |
_onPause() { | |
console.log('%c_onPause:', 'background: cyan;', arguments); | |
this._isPlaying = false; | |
this.emit('pause'); | |
} | |
_onPlay() { | |
console.log('%c_onPlay:', 'background: cyan;', arguments); | |
this.addClass('is-play'); | |
this._isPlaying = true; | |
this.emit('play'); | |
} | |
_onPlaying() { | |
console.log('%c_onPlaying:', 'background: cyan;', arguments); | |
this._isPlaying = true; | |
if (!this._isAspectRatioFixed) { | |
this._isAspectRatioFixed = true; | |
this.emit('aspectRatioFix', | |
this._video.videoHeight / Math.max(1, this._video.videoWidth)); | |
} | |
this.emit('playing'); | |
} | |
_onSeeking() { | |
console.log('%c_onSeeking:', 'background: cyan;', arguments); | |
this.emit('seeking', this._video.currentTime); | |
} | |
_onSeeked() { | |
console.log('%c_onSeeked:', 'background: cyan;', arguments); | |
this.emit('seeked', this._video.currentTime); | |
} | |
_onVolumeChange() { | |
console.log('%c_onVolumeChange:', 'background: cyan;', arguments); | |
this.emit('volumeChange', this.volume, this.muted); | |
} | |
_onDoubleClick(e) { | |
console.log('%c_onDoubleClick:', 'background: cyan;', arguments); | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.emit('dblclick'); | |
} | |
_onMouseWheel(e) { | |
if (e.buttons || e.shiftKey) { | |
return; | |
} | |
console.log('%c_onMouseWheel:', 'background: cyan;', e); | |
e.stopPropagation(); | |
const delta = -parseInt(e.deltaY, 10); | |
if (delta !== 0) { | |
this.emit('mouseWheel', e, delta); | |
} | |
} | |
_onStalled(e) { | |
this.emit('stalled', e); | |
this._video.addEventListener('timeupdate', () => this.emit('timeupdate'), {once: true}); | |
} | |
canPlay() { | |
return !!this._canPlay; | |
} | |
async play() { | |
if (this._currentVideo.currentTime === this.duration) { | |
this.currentTime = 0; | |
} | |
const p = await this._video.play(); | |
this._isPlaying = true; | |
return p; | |
} | |
pause() { | |
this._video.pause(); | |
this._isPlaying = false; | |
return Promise.resolve(); | |
} | |
get isPlaying() { | |
return !!this._isPlaying && !!this._canPlay; | |
} | |
get paused() { | |
return this._video.paused; | |
} | |
set thumbnail(url) { | |
console.log('%csetThumbnail: %s', 'background: cyan;', url); | |
this._thumbnail = url; | |
this._video.poster = url; | |
} | |
get thumbnail() { | |
return this._thumbnail; | |
} | |
set src(url) { | |
console.log('%csetSc: %s', 'background: cyan;', url); | |
this._reset(); | |
this._src = url; | |
this._isPlaying = false; | |
this._canPlay = false; | |
this._isAspectRatioFixed = false; | |
this.addClass('is-loading'); | |
if (/(youtube\.com|youtu\.be)/.test(url)) { | |
const currentTime = this._currentVideo.currentTime; | |
this._initYouTube().then(() => { | |
return this._videoYouTube.setSrc(url, currentTime); | |
}).then(() => { | |
this._changePlayer('YouTube'); | |
}); | |
return; | |
} | |
this._changePlayer('normal'); | |
if (url.indexOf('dmc.nico') >= 0 && location.host.indexOf('.nicovideo.jp') >= 0) { | |
this._video.crossOrigin = 'use-credentials'; | |
} else if (this._video.crossOrigin) { | |
this._video.crossOrigin = null; | |
} | |
this._video.src = url; | |
} | |
get src() {return this._src;} | |
get _isYouTube() {return this._videoYouTube && this._currentVideo === this._videoYouTube;} | |
_initYouTube() { | |
if (this._videoYouTube) { | |
return Promise.resolve(this._videoYouTube); | |
} | |
const yt = this._videoYouTube = new YouTubeWrapper({ | |
parentNode: this._body.appendChild(document.createElement('div')), | |
volume: this._volume, | |
autoplay: this._videoElement.autoplay | |
}); | |
const eventBridge = function(...args) { | |
this.emit(...args); | |
}; | |
yt.on('canplay', this._onCanPlay.bind(this)); | |
yt.on('loadedmetadata', eventBridge.bind(this, 'loadedmetadata')); | |
yt.on('ended', eventBridge.bind(this, 'ended')); | |
yt.on('stalled', eventBridge.bind(this, 'stalled')); | |
yt.on('pause', this._onPause.bind(this)); | |
yt.on('play', this._onPlay.bind(this)); | |
yt.on('playing', this._onPlaying.bind(this)); | |
yt.on('seeking', this._onSeeking.bind(this)); | |
yt.on('seeked', this._onSeeked.bind(this)); | |
yt.on('volumechange', this._onVolumeChange.bind(this)); | |
yt.on('error', this._onYouTubeError.bind(this)); | |
global.debug.youtube = yt; | |
return Promise.resolve(this._videoYouTube); | |
} | |
_changePlayer(type) { | |
switch (type.toLowerCase()) { | |
case 'youtube': | |
if (this._currentVideo !== this._videoYouTube) { | |
const yt = this._videoYouTube; | |
this.addClass('is-youtube'); | |
yt.autoplay = this._currentVideo.autoplay; | |
yt.loop = this._currentVideo.loop; | |
yt.muted = this._currentVideo.muted; | |
yt.volume = this._currentVideo.volume; | |
yt.playbackRate = this._currentVideo.playbackRate; | |
this._currentVideo = yt; | |
this._videoElement.src = CONSTANT.BLANK_VIDEO_URL; | |
this.emit('playerTypeChange', 'youtube'); | |
} | |
break; | |
default: | |
if (this._currentVideo === this._videoYouTube) { | |
this.removeClass('is-youtube'); | |
this._videoElement.loop = this._currentVideo.loop; | |
this._videoElement.muted = this._currentVideo.muted; | |
this._videoElement.volume = this._currentVideo.volume; | |
this._videoElement.playbackRate = this._currentVideo.playbackRate; | |
this._currentVideo = this._videoElement; | |
this._videoYouTube.src = ''; | |
this.emit('playerTypeChange', 'normal'); | |
} | |
break; | |
} | |
} | |
set volume(vol) { | |
vol = Math.max(Math.min(1, vol), 0); | |
this._video.volume = vol; | |
} | |
get volume() {return Math.max(0, this._video.volume);} | |
set muted(v) { | |
v = !!v; | |
if (this._video.muted !== v) { | |
this._video.muted = v; | |
} | |
} | |
get muted() {return this._video.muted;} | |
get currentTime() { | |
if (!this._canPlay) { | |
return 0; | |
} | |
return this._video.currentTime; | |
} | |
set currentTime(sec) { | |
const cur = this._video.currentTime; | |
if (cur !== sec) { | |
this._video.currentTime = sec; | |
this.emit('seek', this._video.currentTime); | |
} | |
} | |
fastSeek(sec) { | |
if (typeof this._video.fastSeek !== 'function' || this._isYouTube) { | |
return this.currentTime=sec; | |
} | |
if (this._src.indexOf('dmc.nico') >= 0) { | |
return this.currentTime = sec; | |
} | |
this._video.fastSeek(sec); | |
this.emit('seek', this._video.currentTime); | |
} | |
get duration() {return this._video.duration;} | |
togglePlay() { | |
if (this.isPlaying) { | |
return this.pause(); | |
} else { | |
return this.play(); | |
} | |
} | |
get vpos() {return this._video.currentTime * 100;} | |
set vpos(vpos) {this._video.currentTime = vpos / 100;} | |
get isLoop() {return !!this._video.loop;} | |
set isLoop(v) {this._video.loop = !!v; } | |
set playbackRate(v) { | |
console.log('setPlaybackRate', v); | |
this._playbackRate = v; | |
const video = this._video; | |
video.playbackRate = 1; | |
window.setTimeout(() => video.playbackRate = parseFloat(v), 100); | |
} | |
get playbackRate() {return this._playbackRate;} | |
get bufferedRange() {return this._video.buffered;} | |
set isAutoPlay(v) {this._video.autoplay = v;} | |
get isAutoPlay() {return this._video.autoPlay;} | |
setSrc(url) { this.src = url;} | |
setVolume(v) { this.volume = v; } | |
getVolume() { return this.volume; } | |
setMute(v) { this.muted = v;} | |
isMuted() { return this.muted; } | |
getDuration() { return this.duration; } | |
getVpos() { return this.vpos; } | |
setVpos(v) { this.vpos = v; } | |
getIsLoop() {return this.isLoop;} | |
setIsLoop(v) {this.isLoop = !!v; } | |
setPlaybackRate(v) { this.playbackRate = v; } | |
getPlaybackRate() { return this.playbackRate; } | |
getBufferedRange() { return this.bufferedRange; } | |
setIsAutoPlay(v) {this.isAutoplay = v;} | |
getIsAutoPlay() {return this.isAutoPlay;} | |
appendTo(node) {node.append(this._body);} | |
close() { | |
this._video.pause(); | |
this._video.removeAttribute('src'); | |
this._video.removeAttribute('poster'); | |
this._videoElement.src = CONSTANT.BLANK_VIDEO_URL; | |
if (this._videoYouTube) { | |
this._videoYouTube.src = ''; | |
} | |
} | |
getScreenShot() { | |
if (!this.isCorsReady) { | |
return null; | |
} | |
const video = this._video; | |
const width = video.videoWidth; | |
const height = video.videoHeight; | |
const canvas = document.createElement('canvas'); | |
canvas.width = width; | |
canvas.height = height; | |
const context = canvas.getContext('2d'); | |
context.drawImage(video.drawableElement || video, 0, 0); | |
return canvas; | |
} | |
get isCorsReady() {return this._video.crossOrigin === 'use-credentials';} | |
get videoElement() {return this._videoElement;} | |
get _video() {return this._currentVideo;} | |
set _video(v) {this._currentVideo = v;} | |
} | |
VideoPlayer.__css__ = ` | |
.videoPlayer iframe, | |
.videoPlayer .zenzaWatchVideoElement { | |
margin: 0; | |
padding: 0; | |
width: 100%; | |
height: 100%; | |
z-index: 5; | |
} | |
.zenzaWatchVideoElement { | |
display: block; | |
transition: transform 0.4s ease; | |
} | |
.is-flipH .zenzaWatchVideoElement { | |
transform: perspective(400px) rotateY(180deg); | |
} | |
.is-flipV .zenzaWatchVideoElement { | |
transform: perspective(400px) rotateX(180deg); | |
} | |
.is-flipV.is-flipH .zenzaWatchVideoElement { | |
transform: perspective(400px) rotateX(180deg) rotateY(180deg); | |
} | |
/* iOSだとvideo上でマウスイベントが発生しないのでカバーを掛ける */ | |
.touchWrapper { | |
display: block; | |
position: absolute; | |
opacity: 0; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
z-index: 10; | |
touch-action: none; | |
} | |
/* YouTubeのプレイヤーを触れる用にするための隙間 */ | |
.is-youtube .touchWrapper { | |
width: calc(100% - 100px); | |
height: calc(100% - 150px); | |
} | |
.is-loading .touchWrapper, | |
.is-error .touchWrapper { | |
display: none !important; | |
} | |
.videoPlayer.is-youtube .zenzaWatchVideoElement { | |
display: none; | |
} | |
.videoPlayer iframe { | |
display: none; | |
} | |
.videoPlayer.is-youtube iframe { | |
display: block; | |
} | |
`.trim(); | |
class TouchWrapper extends Emitter { | |
constructor({parentElement}) { | |
super(); | |
this._parentElement = parentElement; | |
this._config = global.config.namespace('touch'); | |
this._isTouching = false; | |
this._maxCount = 0; | |
this._currentPointers = []; | |
this._debouncedOnSwipe2Y = _.debounce(this._onSwipe2Y.bind(this), 400); | |
this._debouncedOnSwipe3X = _.debounce(this._onSwipe3X.bind(this), 400); | |
this.initializeDom(); | |
} | |
initializeDom() { | |
let body = this._body = document.createElement('div'); | |
body.className = 'touchWrapper'; | |
body.addEventListener('click', this._onClick.bind(this)); | |
body.addEventListener('touchstart', this._onTouchStart.bind(this), {passive: true}); | |
body.addEventListener('touchmove', this._onTouchMove.bind(this), {passive: true}); | |
body.addEventListener('touchend', this._onTouchEnd.bind(this), {passive: true}); | |
body.addEventListener('touchcancel', this._onTouchCancel.bind(this), {passive: true}); | |
this._onTouchMoveThrottled = | |
_.throttle(this._onTouchMoveThrottled.bind(this), 200); | |
if (this._parentElement) { | |
this._parentElement.appendChild(body); | |
} | |
global.debug.touchWrapper = this; | |
} | |
get body() { | |
return this._body; | |
} | |
_onClick() { | |
this._lastTap = 0; | |
} | |
_onTouchStart(e) { | |
let identifiers = | |
this._currentPointers.map(touch => { | |
return touch.identifier; | |
}); | |
if (e.changedTouches.length > 1) { | |
e.preventDefault(); | |
} | |
[...e.changedTouches].forEach(touch => { | |
if (identifiers.includes(touch.identifier)) { | |
return; | |
} | |
this._currentPointers.push(touch); | |
}); | |
this._maxCount = Math.max(this._maxCount, this.touchCount); | |
this._startCenter = this._getCenter(e); | |
this._lastCenter = this._getCenter(e); | |
this._isMoved = false; | |
} | |
_onTouchMove(e) { | |
if (e.targetTouches.length > 1) { | |
e.preventDefault(); | |
} | |
this._onTouchMoveThrottled(e); | |
} | |
_onTouchMoveThrottled(e) { | |
if (!e.targetTouches) { | |
return; | |
} | |
if (e.targetTouches.length > 1) { | |
e.preventDefault(); | |
} | |
let startPoint = this._startCenter; | |
let lastPoint = this._lastCenter; | |
let currentPoint = this._getCenter(e); | |
if (!startPoint || !currentPoint) { | |
return; | |
} | |
let width = this._body.offsetWidth; | |
let height = this._body.offsetHeight; | |
let diff = { | |
count: this.touchCount, | |
startX: startPoint.x, | |
startY: startPoint.y, | |
currentX: currentPoint.x, | |
currentY: currentPoint.y, | |
moveX: currentPoint.x - lastPoint.x, | |
moveY: currentPoint.y - lastPoint.y, | |
x: currentPoint.x - startPoint.x, | |
y: currentPoint.y - startPoint.y, | |
}; | |
diff.perX = diff.x / width * 100; | |
diff.perY = diff.y / height * 100; | |
diff.perStartX = diff.startX / width * 100; | |
diff.perStartY = diff.startY / height * 100; | |
diff.movePerX = diff.moveX / width * 100; | |
diff.movePerY = diff.moveY / height * 100; | |
if (Math.abs(diff.perX) > 2 || Math.abs(diff.perY) > 1) { | |
this._isMoved = true; | |
} | |
if (diff.count === 2) { | |
if (Math.abs(diff.movePerX) >= 0.5) { | |
this._execCommand('seekRelativePercent', diff); | |
} | |
if (Math.abs(diff.perY) >= 20) { | |
this._debouncedOnSwipe2Y(diff); | |
} | |
} | |
if (diff.count === 3) { | |
if (Math.abs(diff.perX) >= 20) { | |
this._debouncedOnSwipe3X(diff); | |
} | |
} | |
this._lastCenter = currentPoint; | |
return diff; | |
} | |
_onSwipe2Y(diff) { | |
this._execCommand(diff.perY < 0 ? 'shiftUp' : 'shiftDown'); | |
this._startCenter = this._lastCenter; | |
} | |
_onSwipe3X(diff) { | |
this._execCommand(diff.perX < 0 ? 'playNextVideo' : 'playPreviousVideo'); | |
this._startCenter = this._lastCenter; | |
} | |
_execCommand(command, param) { | |
if (!this._config.props.enable) { | |
return; | |
} | |
if (!command) { | |
return; | |
} | |
this.emit('command', command, param); | |
} | |
_onTouchEnd(e) { | |
if (!e.changedTouches) { | |
return; | |
} | |
let identifiers = | |
Array.from(e.changedTouches).map(touch => { | |
return touch.identifier; | |
}); | |
let currentTouches = []; | |
currentTouches = this._currentPointers.filter(touch => { | |
return !identifiers.includes(touch.identifier); | |
}); | |
this._currentPointers = currentTouches; | |
if (!this._isMoved && this.touchCount === 0) { | |
const config = this._config; | |
this._lastTap = this._maxCount; | |
window.console.info('touchEnd', this._maxCount, this._isMoved); | |
switch (this._maxCount) { | |
case 2: | |
this._execCommand(config.props.tap2command); | |
break; | |
case 3: | |
this._execCommand(config.props.tap3command); | |
break; | |
case 4: | |
this._execCommand(config.props.tap4command); | |
break; | |
case 5: | |
this._execCommand(config.props.tap5command); | |
break; | |
} | |
this._maxCount = 0; | |
this._isMoved = false; | |
} | |
} | |
_onTouchCancel(e) { | |
if (!e.changedTouches) { | |
return; | |
} | |
let identifiers = | |
Array.from(e.changedTouches).map(touch => { | |
return touch.identifier; | |
}); | |
let currentTouches = []; | |
window.console.log('onTouchCancel', this._isMoved, e.changedTouches.length); | |
currentTouches = this._currentPointers.filter(touch => { | |
return !identifiers.includes(touch.identifier); | |
}); | |
this._currentPointers = currentTouches; | |
} | |
get touchCount() { | |
return this._currentPointers.length; | |
} | |
_getCenter(e) { | |
let x = 0, y = 0; | |
Array.from(e.touches).forEach(t => { | |
x += t.pageX; | |
y += t.pageY; | |
}); | |
return {x: x / e.touches.length, y: y / e.touches.length}; | |
} | |
} | |
class StoryboardInfoModel extends Emitter { | |
static get blankData() { | |
return { | |
format: 'dmc', | |
status: 'fail', | |
duration: 1, | |
storyboard: [{ | |
id: 1, | |
urls: ['https://example.com'], | |
thumbnail: { | |
width: 160, | |
height: 90, | |
number: 1, | |
interval: 1000 | |
}, | |
board: { | |
rows: 1, | |
cols: 1, | |
number: 1 | |
} | |
}] | |
}; | |
} | |
constructor(rawData) { | |
super(); | |
this.update(rawData); | |
} | |
update(rawData) { | |
if (!rawData || rawData.status !== 'ok') { | |
this._rawData = this.constructor.blankData; | |
} else { | |
this._rawData = rawData; | |
} | |
this.primary = this._rawData.storyboard[0]; | |
this.emit('update', this); | |
return this; | |
} | |
reset() { | |
this._rawData = this.constructor.blankData; | |
this.emit('reset'); | |
} | |
get rawData() { | |
return this._rawData || this.constructor.blankData; | |
} | |
get isAvailable() {return this._rawData.status === 'ok';} | |
get hasSubStoryboard() { return false; } | |
get status() {return this._rawData.status;} | |
get message() {return this._rawData.message;} | |
get duration() {return this._rawData.duration * 1;} | |
get isDmc() {return this._rawData.format === 'dmc';} | |
get url() { | |
return this.isDmc ? this.primary.urls[0] : this.primary.url; | |
} | |
get imageUrls() { | |
return [...Array(this.pageCount)].map((a, i) => this.getPageUrl(i)); | |
} | |
get cellWidth() { return this.primary.thumbnail.width * 1; } | |
get cellHeight() { return this.primary.thumbnail.height * 1; } | |
get cellIntervalMs() { return this.primary.thumbnail.interval * 1; } | |
get cellCount() { | |
return Math.max( | |
Math.ceil(this.duration / Math.max(0.01, this.cellIntervalMs)), | |
this.primary.thumbnail.number * 1 | |
); | |
} | |
get rows() { return this.primary.board.rows * 1; } | |
get cols() { return this.primary.board.cols * 1; } | |
get pageCount() { return this.primary.board.number * 1; } | |
get totalRows() { return Math.ceil(this.cellCount / this.cols); } | |
get pageWidth() { return this.cellWidth * this.cols; } | |
get pageHeight() { return this.cellHeight * this.rows; } | |
get countPerPage() { return this.rows * this.cols; } | |
} | |
class StoryboardView extends Emitter { | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
initialize(params) { | |
console.log('%c initialize StoryboardView', 'background: lightgreen;'); | |
this._container = params.container; | |
const sb = this._model = params.model; | |
this._isHover = false; | |
this._scrollLeft = 0; | |
this._pointerLeft = 0; | |
this.isOpen = false; | |
this.isEnable = _.isBoolean(params.enable) ? params.enable : true; | |
this.totalWidth = global.innerWidth; | |
this.state = params.state; | |
this.state.onkey('isDragging', () => this.updateAnimation()); | |
sb.on('update', this._onStoryboardUpdate.bind(this)); | |
sb.on('reset', this._onStoryboardReset.bind(this)); | |
} | |
get isHover() { | |
return this._isHover; | |
} | |
set isHover(v) { | |
this._isHover = v; | |
this.updateAnimation(); | |
} | |
updateAnimation() { | |
if (!this.canvas || !MediaTimeline.isSharable) { return; } | |
if (!this.isHover && this.isOpen && !this.state.isDragging) { | |
this.canvas.startAnimation(); | |
} else { | |
this.canvas.stopAnimation(); | |
} | |
} | |
enable() { | |
this.isEnable = true; | |
if (this._view && this._model.isAvailable) { | |
this.open(); | |
} | |
} | |
open() { | |
if (!this._view) { | |
return; | |
} | |
this.isOpen = true; | |
ClassList(this._view).add('is-open'); | |
ClassList(this._body).add('zenzaStoryboardOpen'); | |
ClassList(this._container).add('zenzaStoryboardOpen'); | |
this.updateAnimation(); | |
this.updatePointer(); | |
} | |
close() { | |
if (!this._view) { | |
return; | |
} | |
this.isOpen = false; | |
ClassList(this._view).remove('is-open'); | |
ClassList(this._body).remove('zenzaStoryboardOpen'); | |
ClassList(this._container).remove('zenzaStoryboardOpen'); | |
this.updateAnimation(); | |
} | |
disable() { | |
this.isEnable = false; | |
this.close(); | |
} | |
toggle(v) { | |
if (typeof v === 'boolean') { | |
this.isEnable = !v; | |
} | |
if (this.isEnable) { | |
this.disable(); | |
} else { | |
this.enable(); | |
} | |
} | |
_initializeStoryboard() { | |
if (this._view) { return; } | |
window.console.log('%cStoryboardView.initializeStoryboard', 'background: lightgreen;'); | |
this._body = document.body; | |
cssUtil.addStyle(StoryboardView.__css__); | |
const view = this._view = uq.html(StoryboardView.__tpl__)[0]; | |
const inner = this._inner = view.querySelector('.storyboardInner'); | |
this._cursorTime = view.querySelector('.cursorTime'); | |
this._pointer = view.querySelector('.storyboardPointer'); | |
this._inner = inner; | |
this.cursorTimeLabel = TextLabel.create({ | |
container: this._cursorTime, | |
name: 'cursorTimeLabel', | |
text: '00:00', | |
style: { | |
widthPx: 54, | |
heightPx: 29, | |
fontFamily: 'monospace', | |
fontWeight: '', | |
fontSizePx: 13.3, | |
color: '#000' | |
} | |
}); | |
const onHoverIn = () => this.isHover = true; | |
const onHoverOut = () => this.isHover = false; | |
uq(inner) | |
.on('click', this._onBoardClick.bind(this)) | |
.on('mousemove', this._onBoardMouseMove.bind(this)) | |
.on('mousemove', _.debounce(this._onBoardMouseMoveEnd.bind(this), 300)) | |
.on('wheel', this._onMouseWheel.bind(this)) | |
.on('wheel', _.debounce(this._onMouseWheelEnd.bind(this), 300), {passive: true}) | |
.on('mouseenter', onHoverIn) | |
.on('mouseleave', _.debounce(onHoverOut, 1000)) | |
.on('touchstart', this._onTouchStart.bind(this), {passive: true}) | |
.on('touchmove', this._onTouchMove.bind(this), {passive: true}); | |
this._bouncedOnToucheMoveEnd = _.debounce(this._onTouchMoveEnd.bind(this), 2000); | |
this._container.append(view); | |
view.closest('.zen-root') | |
.addEventListener('touchend', () => this.isHover = false, {passive: true}); | |
window.addEventListener('resize', | |
_.throttle(() => { | |
if (this.canvas) { | |
this.canvas.resize({width: global.innerWidth, height: this._model.cellHeight}); | |
} | |
}, 500), {passive: true}); | |
this.emitResolve('dom-ready'); | |
} | |
_parsePointerEvent(event) { | |
const model = this._model; | |
const left = event.offsetX + this._scrollLeft; | |
const cellIndex = left / model.cellWidth; | |
const sec = cellIndex * model.cellIntervalMs / 1000; | |
return {sec, x: event.x}; | |
} | |
_onBoardClick(e) { | |
const {sec} = this._parsePointerEvent(e); | |
cssUtil.setProps([this._cursorTime, '--trans-x-pp', cssUtil.px(-1000)]); | |
domEvent.dispatchCommand(this._view, 'seekTo', sec); | |
} | |
_onBoardMouseMove(e) { | |
const {sec, x} = this._parsePointerEvent(e); | |
this.cursorTimeLabel.text = textUtil.secToTime(sec); | |
cssUtil.setProps([this._cursorTime, '--trans-x-pp', cssUtil.px(x)]); | |
this.isHover = true; | |
this.isMouseMoving = true; | |
} | |
_onBoardMouseMoveEnd(e) { | |
this.isMouseMoving = false; | |
} | |
_onMouseWheel(e) { | |
e.stopPropagation(); | |
const deltaX = parseInt(e.deltaX, 10); | |
const delta = parseInt(e.deltaY, 10); | |
if (Math.abs(deltaX) > Math.abs(delta)) { | |
return; | |
} | |
e.preventDefault(); | |
this.isHover = true; | |
this.isMouseMoving = true; | |
this.scrollLeft += delta * 5; | |
} | |
_onMouseWheelEnd() { | |
this.isMouseMoving = false; | |
} | |
_onTouchStart(e) { | |
this.isHover = true; | |
this.isMouseMoving = true; | |
e.stopPropagation(); | |
} | |
_onTouchEnd() { | |
} | |
_onTouchMove(e) { | |
e.stopPropagation(); | |
this.isHover = true; | |
this.isMouseMoving = true; | |
this.isTouchMoving = true; | |
this._bouncedOnToucheMoveEnd(); | |
} | |
_onTouchMoveEnd() { | |
this.isTouchMoving = false; | |
this.isMouseMoving = false; | |
} | |
_onTouchCancel() { | |
} | |
update() { | |
this.isHover = false; | |
this._scrollLeft = 0; | |
this._initializeStoryboard(this._model); | |
this.close(); | |
ClassList(this._view).remove('is-success', 'is-fail'); | |
if (this._model.status === 'ok') { | |
this._updateSuccess(); | |
} else { | |
this._updateFail(); | |
} | |
} | |
get isCanvasAnimating() { | |
return this.isEnable && this.canvas.isAnimating; | |
} | |
get scrollLeft() { | |
return this._scrollLeft; | |
} | |
set scrollLeft(left) { | |
left = Math.min(Math.max(0, left), this.totalWidth - global.innerWidth); | |
if (this._scrollLeft === left) { | |
return; | |
} | |
this._scrollLeftChanged = true; | |
this._scrollLeft = left; | |
!this.isCanvasAnimating && (this.isOpen || this.state.isDragging) && (this.canvas.scrollLeft = left); | |
this.updatePointer(); | |
} | |
get pointerLeft() { | |
return this._pointerLeft; | |
} | |
set pointerLeft(left) { | |
if (this._pointerLeft === left) { | |
return; | |
} | |
this._pointerLeftChanged = true; | |
this._pointerLeft = left; | |
this.updatePointer(); | |
} | |
updatePointer() { | |
if (!this._pointer || !this.isOpen || this._pointerUpdating || | |
(this.isCanvasAnimating && !this.isHover) || | |
(!this._pointerLeftChanged && !this._scrollLeftChanged)) { | |
return; | |
} | |
this._pointerUpdating = true; | |
this._pointerLeftChanged = false; | |
this._scrollLeftChanged = false; | |
cssUtil.setProps([this._pointer, '--trans-x-pp', | |
cssUtil.px(this._pointerLeft - this._scrollLeft - this._model.cellWidth / 2)]); | |
this._pointerUpdating = false; | |
} | |
_updateSuccess() { | |
const view = this._view; | |
const cl = ClassList(view); | |
cl.add('is-success'); | |
window.console.time('createStoryboardDOM'); | |
this._updateSuccessDom(); | |
window.console.timeEnd('createStoryboardDOM'); | |
if (!this.isEnable) { | |
return; | |
} | |
cl.add('opening', 'is-open'); | |
this.scrollLeft = 0; | |
this.open(); | |
window.setTimeout(() => cl.remove('opening'), 1000); | |
} | |
_updateSuccessDom() { | |
const model = this._model; | |
const infoRawData = model.rawData; | |
if (!this.canvas) { | |
this.canvas = StoryboardWorker.createBoard({ | |
container: this._view.querySelector('.storyboardCanvasContainer'), | |
canvas: this._view.querySelector('.storyboardCanvas'), | |
info: infoRawData, | |
name: 'StoryboardCanvasView' | |
}); | |
this.canvas.resize({width: global.innerWidth, height: model.cellHeight}); | |
if (MediaTimeline.isSharable) { | |
const mt = MediaTimeline.get('main'); | |
this.canvas.currentTime = mt.currentTime; | |
this.canvas.sharedMemory({buffer: mt.buffer, MAP: MediaTimeline.MAP}); | |
} | |
} else { | |
this.canvas.setInfo(infoRawData); | |
this.canvas.resize({width: global.innerWidth, height: model.cellHeight}); | |
} | |
this.totalWidth = Math.ceil(model.duration * 1000 / model.cellIntervalMs) * model.cellWidth; | |
cssUtil.setProps( | |
[this._pointer, '--width-pp', cssUtil.px(model.cellWidth)], | |
[this._pointer, '--height-pp', cssUtil.px(model.cellHeight)], | |
[this._inner, '--height-pp', cssUtil.px(model.cellHeight + 8)] | |
); | |
} | |
_updateFail() { | |
ClassList(this._view).remove('is-uccess').add('is-fail'); | |
} | |
setCurrentTime(sec, forceUpdate) { | |
const model = this._model; | |
if (!this._view || !model.isAvailable) { | |
return; | |
} | |
if (this._currentTime === sec) { | |
return; | |
} | |
this._currentTime = sec; | |
const duration = Math.max(1, model.duration); | |
const per = sec / duration; | |
const intervalMs = model.cellIntervalMs; | |
const totalWidth = this.totalWidth; | |
const innerWidth = global.innerWidth; | |
const cellWidth = model.cellWidth; | |
const cellIndex = sec * 1000 / intervalMs; | |
const scrollLeft = | |
Math.min(Math.max(cellWidth * cellIndex - innerWidth * per, 0), totalWidth - innerWidth); | |
if (forceUpdate || !this.isHover) { | |
this.scrollLeft = scrollLeft; | |
} | |
this.pointerLeft = cellWidth * cellIndex; | |
} | |
get currentTime() { | |
return this._currentTime; | |
} | |
set currentTime(sec) { | |
this.setCurrentTime(sec); | |
} | |
_onStoryboardUpdate() { | |
this.update(); | |
} | |
_onStoryboardReset() { | |
if (!this._view) { | |
return; | |
} | |
this.close(); | |
ClassList(this._view).remove('is-open', 'is-fail'); | |
} | |
} | |
StoryboardView.__tpl__ = ` | |
<div id="storyboardContainer" class="storyboardContainer"> | |
<div class="cursorTime"></div> | |
<div class="storyboardCanvasContainer"><canvas class="storyboardCanvas is-loading" height="90"></canvas></div> | |
<div class="storyboardPointer"></div> | |
<div class="storyboardInner"></div> | |
</div> | |
`.trim(); | |
StoryboardView.__css__ = (` | |
.storyboardContainer { | |
position: absolute; | |
top: 0; | |
opacity: 0; | |
visibility: hidden; | |
left: 0; | |
right: 0; | |
width: 100vw; | |
box-sizing: border-box; | |
z-index: 9005; | |
overflow: hidden; | |
pointer-events: none; | |
will-change: tranform; | |
display: none; | |
contain: layout paint style; | |
user-select: none; | |
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out, visibility 0.2s; | |
} | |
.storyboardContainer.opening { | |
pointer-events: none !important; | |
} | |
.storyboardContainer.is-success { | |
display: block; | |
opacity: 0; | |
} | |
.storyboardContainer * { | |
box-sizing: border-box; | |
} | |
.is-wheelSeeking .storyboardContainer.is-success, | |
.is-dragging .storyboardContainer.is-success, | |
.storyboardContainer.is-success.is-open { | |
z-index: 50; | |
opacity: 1; | |
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; | |
visibility: visible; | |
pointer-events: auto; | |
transform: translate3d(0, -100%, 0) translateY(10px); | |
} | |
.is-wheelSeeking .storyboardContainer, | |
.is-dragging .storyboardContainer { | |
pointer-events: none; | |
} | |
.is-fullscreen .is-wheelSeeking .storyboardContainer, | |
.is-fullscreen .is-dragging .storyboardContainer, | |
.is-fullscreen .storyboardContainer.is-open { | |
position: fixed; | |
top: calc(100% - 10px); | |
} | |
.storyboardCanvasContainer { | |
position: absolute; | |
pointer-events: none; | |
width: 100vw; | |
z-index: 90; | |
contain: layout size style; | |
} | |
.storyboardCanvas { | |
width: 100%; | |
height: 100%; | |
opacity: 1; | |
transition: opacity 0.5s ease 0.5s; | |
} | |
.storyboardCanvas.is-loading { | |
opacity: 0; | |
transition: none; | |
} | |
.storyboardContainer .storyboardInner { | |
--height-pp: 98px; | |
height: var(--height-pp); | |
display: none; | |
overflow: hidden; | |
margin: 0; | |
contain: strict; | |
width: 100vw; | |
overscroll-behavior: none; | |
} | |
.storyboardContainer.is-success .storyboardInner { | |
display: block; | |
} | |
.storyboardContainer .cursorTime { | |
display: none; | |
position: absolute; | |
top: 12px; | |
left: 0; | |
width: 54px; height: 29px; | |
z-index: 9010; | |
background: #ffc; | |
pointer-events: none; | |
contain: strict; | |
transform: translate(var(--trans-x-pp), 30px) translate(-50%, -100%); | |
} | |
.storyboardContainer:hover .cursorTime { | |
transition: --trans-x-pp 0.1s ease-out; | |
display: block; | |
} | |
.storyboardContainer:active .cursorTime, | |
.storyboardContainer.opening .cursorTime { | |
display: none; | |
} | |
.storyboardPointer { | |
visibility: hidden; | |
position: absolute; | |
top: 0; | |
z-index: 100; | |
pointer-events: none; | |
--width-pp: 160px; | |
--height-pp: 90px; | |
--trans-x-pp: -100%; | |
width: var(--width-pp); | |
height: var(--height-pp); | |
will-change: transform; | |
transform: translate(var(--trans-x-pp), 0); | |
background: #ff9; | |
opacity: 0.5; | |
} | |
.storyboardContainer:hover .storyboardPointer { | |
visibility: visible; | |
transition: --trans-x-pp 0.4s ease-out; | |
} | |
`).trim(); | |
class SeekBarThumbnail { | |
constructor(params) { | |
this._container = params.container; | |
this._scale = _.isNumber(params.scale) ? params.scale : 1.0; | |
this._currentTime = 0; | |
params.storyboard.on('reset', this._onStoryboardReset.bind(this)); | |
params.storyboard.on('update', this._onStoryboardUpdate.bind(this)); | |
global.debug.seekBarThumbnail = this; | |
} | |
_onStoryboardUpdate(model) { | |
this._model = model; | |
if (!model.isAvailable) { | |
this.isAvailable = false; | |
this.hide(); | |
return; | |
} | |
this.thumbnail ? this.thumbnail.setInfo(model.rawData) : this.initializeView(model); | |
this.isAvailable = true; | |
this.show(); | |
} | |
_onStoryboardReset() { | |
this.hide(); | |
} | |
get isVisible() { | |
return this._view ? this.classList.contains('is-visible') : false; | |
} | |
show() { | |
if (!this._view) { | |
return; | |
} | |
this.classList.add('is-visible'); | |
} | |
hide() { | |
if (!this._view) { | |
return; | |
} | |
this.classList.remove('is-visible'); | |
} | |
initializeView(model) { | |
if (this.thumbnail) { return; } | |
if (!SeekBarThumbnail.styleAdded) { | |
cssUtil.addStyle(SeekBarThumbnail.__css__); | |
SeekBarThumbnail.styleAdded = true; | |
} | |
const view = this._view = uQuery.html(SeekBarThumbnail.__tpl__)[0]; | |
this.classList = ClassList(view); | |
this.thumbnail = StoryboardWorker.createThumbnail({ | |
container: view.querySelector('.zenzaSeekThumbnail-image'), | |
canvas: view.querySelector('.zenzaSeekThumbnail-thumbnail'), | |
info: model.rawData, | |
name: 'StoryboardThumbnail' | |
}); | |
if (this._container) { | |
this._container.append(view); | |
} | |
} | |
set currentTime(sec) { | |
this._currentTime = sec; | |
if (!this.isAvailable || !this.thumbnail) { | |
return; | |
} | |
this.thumbnail.currentTime = sec; | |
} | |
} | |
SeekBarThumbnail.BASE_WIDTH = 160; | |
SeekBarThumbnail.BASE_HEIGHT = 90; | |
SeekBarThumbnail.__tpl__ = (` | |
<div class="zenzaSeekThumbnail"> | |
<div class="zenzaSeekThumbnail-image"><canvas width="160" height="90" class="zenzaSeekThumbnail-thumbnail"></canvas></div> | |
</div> | |
`).trim(); | |
SeekBarThumbnail.__css__ = (` | |
.is-error .zenzaSeekThumbnail, | |
.is-loading .zenzaSeekThumbnail { | |
display: none !important; | |
} | |
.zenzaSeekThumbnail { | |
display: none; | |
pointer-events: none; | |
} | |
.zenzaSeekThumbnail-image { | |
width: 160px; | |
height: 90px; | |
opacity: 0.8; | |
margin: auto; | |
background: #999; | |
} | |
.enableCommentPreview .zenzaSeekThumbnail { | |
width: 100%; | |
height: 100%; | |
display: none !important; | |
} | |
.zenzaSeekThumbnail.is-visible { | |
display: block; | |
overflow: hidden; | |
box-sizing: border-box; | |
background: rgba(0, 0, 0, 0.3); | |
margin: 0 auto 4px; | |
z-index: 100; | |
} | |
`).trim(); | |
const StoryboardWorker = (() => { | |
const func = function(self) { | |
const SCROLL_BAR_WIDTH = 8; | |
const BLANK_SRC = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAE0lEQVQoU2NkYGD4z4AHMI4MBQCFZAgB+jxHYAAAAABJRU5ErkJggg=='; | |
let BLANK_IMG; | |
const items = {}; | |
const getCanvas = (width, height) => { | |
if (self.OffscreenCanvas) { | |
return new OffscreenCanvas(width, height); | |
} | |
const canvas = document.createElement('canvas'); | |
canvas.width = width; | |
canvas.height = height; | |
return canvas; | |
}; | |
const a2d = (arrayBuffer, type = 'image/jpeg') => { | |
return new Promise((ok, ng) => { | |
const reader = new FileReader(); | |
reader.onload = () => ok(reader.result); | |
reader.onerror = ng; | |
reader.readAsDataURL(new Blob([arrayBuffer], {type})); | |
}); | |
}; | |
const loadImage = async src => { | |
try { | |
if (self.createImageBitmap) { | |
return createImageBitmap( | |
src instanceof ArrayBuffer ? | |
new Blob([src], {type: 'image/jpeg'}) : | |
(await fetch(src).then(r => r.blob())) | |
); | |
} else { | |
const img = new Image(); | |
img.src = src instanceof ArrayBuffer ? (await a2d(src)) : src; | |
await img.decode(); | |
return img; | |
} | |
} catch(e) { | |
console.warn('load image fail', e); | |
return BLANK_IMG; | |
} | |
}; | |
loadImage(BLANK_SRC).then(img => BLANK_IMG = img); | |
const ImageCacheMap = new class { | |
constructor() { | |
this.map = new Map(); | |
} | |
async get(src) { | |
let cache = this.map.get(src); | |
if (!cache) { | |
cache = { | |
ref: 0, | |
image: await loadImage(src) | |
}; | |
} | |
cache.ref++; | |
cache.updated = Date.now(); | |
this.map.set(src, cache); | |
this.gc(); | |
return cache.image; | |
} | |
release(src) { | |
const cache = this.map.get(src); | |
if (!cache) { | |
return; | |
} | |
cache.ref--; | |
if (cache.ref <= 0) { | |
cache.image.close && cache.image.close(); | |
this.map.delete(src); | |
} | |
} | |
async gc() { | |
const MAX = 8; | |
const map = this.map; | |
if (map.size < MAX) { | |
return; | |
} | |
const sorted = [...map].sort((a, b) => a[1].updated - b[1].updated); | |
while (map.size >= MAX) { | |
const [src] = sorted.shift(); | |
const cache = map.get(src); | |
cache && cache.image && cache.image.close && cache.image.close(); | |
map.delete(src); | |
} | |
} | |
}; | |
class StoryboardInfoModel { | |
static get blankData() { | |
return { | |
format: 'dmc', | |
status: 'fail', | |
duration: 1, | |
storyboard: [{ | |
id: 1, | |
urls: ['https://example.com'], | |
thumbnail: { | |
width: 160, | |
height: 90, | |
number: 1, | |
interval: 1000 | |
}, | |
board: { | |
rows: 1, | |
cols: 1, | |
number: 1 | |
} | |
}] | |
}; | |
} | |
constructor(rawData) { | |
this.update(rawData); | |
} | |
update(rawData) { | |
if (!rawData || rawData.status !== 'ok') { | |
this._rawData = this.constructor.blankData; | |
} else { | |
this._rawData = rawData; | |
} | |
this.primary = this._rawData.storyboard[0]; | |
return this; | |
} | |
get rawData() { | |
return this._rawData || this.constructor.blankData; | |
} | |
get isAvailable() {return this._rawData.status === 'ok';} | |
get hasSubStoryboard() { return false; } | |
get status() {return this._rawData.status;} | |
get message() {return this._rawData.message;} | |
get duration() {return this._rawData.duration * 1;} | |
get isDmc() {return this._rawData.format === 'dmc';} | |
get url() { | |
return this.isDmc ? this.primary.urls[0] : this.primary.url; | |
} | |
get imageUrls() { | |
return [...Array(this.pageCount)].map((a, i) => this.getPageUrl(i)); | |
} | |
get cellWidth() { return this.primary.thumbnail.width * 1; } | |
get cellHeight() { return this.primary.thumbnail.height * 1; } | |
get cellIntervalMs() { return this.primary.thumbnail.interval * 1; } | |
get cellCount() { | |
return Math.max( | |
Math.ceil(this.duration / Math.max(0.01, this.cellIntervalMs)), | |
this.primary.thumbnail.number * 1 | |
); | |
} | |
get rows() { return this.primary.board.rows * 1; } | |
get cols() { return this.primary.board.cols * 1; } | |
get pageCount() { return this.primary.board.number * 1; } | |
get totalRows() { return Math.ceil(this.cellCount / this.cols); } | |
get pageWidth() { return this.cellWidth * this.cols; } | |
get pageHeight() { return this.cellHeight * this.rows; } | |
get countPerPage() { return this.rows * this.cols; } | |
getPageUrl(page) { | |
if (!this.isDmc) { | |
page = Math.max(0, Math.min(this.pageCount - 1, page)); | |
return `${this.url}&board=${page + 1}`; | |
} else { | |
return this.primary.urls[page]; | |
} | |
} | |
getIndex(ms) { | |
let v = Math.floor(ms / 1000); | |
v = Math.max(0, Math.min(this.duration, v)); | |
const n = this.cellCount / Math.max(1, this.duration); | |
return parseInt(Math.floor(v * n), 10); | |
} | |
getPageIndex(thumbnailIndex) { | |
const perPage = this.countPerPage; | |
const pageIndex = parseInt(thumbnailIndex / perPage, 10); | |
return Math.max(0, Math.min(this.pageCount, pageIndex)); | |
} | |
getThumbnailPosition(ms) { | |
const index = this.getIndex(ms); | |
const page = this.getPageIndex(index); | |
const mod = index % this.countPerPage; | |
const row = Math.floor(mod / Math.max(1, this.cols)); | |
const col = mod % this.rows; | |
return { | |
page, | |
url: this.getPageUrl(page), | |
index, | |
row, | |
col | |
}; | |
} | |
} | |
class BoardView { | |
constructor({canvas, info, name}) { | |
this.canvas = canvas; | |
this.name = name; | |
this._currentTime = -1; | |
this._scrollLeft = 0; | |
this._info = null; | |
this.lastPos = {}; | |
this.ctx = canvas.getContext('2d', {alpha: false, desynchronized: true}); | |
this.bitmapCtx = canvas.getContext('bitmaprenderer'); | |
this.bufferCanvas = getCanvas(canvas.width, canvas.height); | |
this.bufferCtx = this.bufferCanvas.getContext('2d', {alpha: false, desynchronized: true}); | |
this.images = ImageCacheMap; | |
this.totalWidth = 0; | |
this.isReady = false; | |
this.boards = []; | |
this.isAnimating = false; | |
this.cls(); | |
if (info) { | |
this.isInitialized = this.setInfo(info); | |
} else { | |
this.isInitialized = Promise.resolve(); | |
} | |
} | |
get info() { return this._info; } | |
set info(infoRawData) { this.setInfo(infoRawData); } | |
async setInfo(infoRawData) { | |
this.isReady = false; | |
this.info ? this._info.update(infoRawData) : (this._info = new StoryboardInfoModel(infoRawData)); | |
const info = this.info; | |
if (!info.isAvailable) { | |
return this.cls(); | |
} | |
console.time('BoardView setInfo'); | |
const cols = info.cols; | |
const rows = info.rows; | |
const pageWidth = info.pageWidth; | |
const boardWidth = pageWidth * rows; | |
const cellWidth = info.cellWidth; | |
const cellHeight = info.cellHeight; | |
this.height = cellHeight; | |
this.totalWidth = Math.ceil(info.duration * 1000 / info.cellIntervalMs) * cellWidth; | |
this.boards.forEach(board => board.image && board.image.close && board.image.close()); | |
this.boards = (await Promise.all(this._info.imageUrls.map(async (url, idx) => { | |
const image = await this.images.get(url); | |
const boards = []; | |
for (let row = 0; row < rows; row++) { | |
const canvas = getCanvas(pageWidth, cellHeight); | |
const ctx = canvas.getContext('2d', {alpha: false, desynchronized: true}); | |
ctx.beginPath(); | |
const sy = row * cellHeight; | |
ctx.drawImage(image, | |
0, sy, pageWidth, cellHeight, | |
0, 0, pageWidth, cellHeight | |
); | |
ctx.strokeStyle = 'rgb(128, 128, 128)'; | |
ctx.shadowColor = 'rgb(192, 192, 192)'; | |
ctx.shadowOffsetX = -1; | |
for (let col = 0; col < cols; col++) { | |
const x = col * cellWidth; | |
ctx.strokeRect(x, 1, cellWidth - 1 , cellHeight + 2); | |
} | |
boards.push({ | |
image: canvas, //.transferToImageBitmap ? canvas.transferToImageBitmap() : canvas, // ImageBitmapじゃないほうが速い?気のせい? | |
left: idx * boardWidth + row * pageWidth, | |
right: idx * boardWidth + row * pageWidth + pageWidth, | |
width: pageWidth | |
}); | |
} | |
this.images.release(url); | |
return boards; | |
}))).flat(); | |
this.height = info.cellHeight; | |
this._currentTime = -1; | |
this.cls(); | |
console.timeEnd('BoardView setInfo'); | |
this.isReady = true; | |
this.reDraw(); | |
} | |
reDraw() { | |
const left = this._scrollLeft; | |
this._scrollLeft = -1; | |
this.scrollLeft = left; | |
} | |
get scrollLeft() { | |
return this._scrollLeft; | |
} | |
set scrollLeft(left) { | |
left = Math.max(0, Math.min(this.totalWidth - this.width, left)); | |
if (this._scrollLeft === left) { | |
return; | |
} | |
this._scrollLeft = left; | |
if (!this.info || !this.info.isAvailable || !this.isReady) { | |
return; | |
} | |
const width = this.width; | |
const height = this.height; | |
const totalWidth = this.totalWidth; | |
const right = left + width; | |
const bctx = this.bufferCtx; | |
bctx.beginPath(); | |
for (const board of this.boards) { | |
if (board.right < left) { continue; } | |
if (board.left > right) { break; } | |
const dx = board.left - left; | |
bctx.drawImage(board.image, | |
0, 0, board.width, height, | |
dx, 0, board.width, height | |
); | |
} | |
const scrollBarLength = width * width / totalWidth; | |
if (scrollBarLength < width) { | |
const scrollBarLeft = width * left / totalWidth; | |
bctx.fillStyle = 'rgba(240, 240, 240, 0.8)'; | |
bctx.fillRect(scrollBarLeft, height - SCROLL_BAR_WIDTH, scrollBarLength, SCROLL_BAR_WIDTH); | |
} | |
if (this.isAnimating && this._currentTime >= 0) { | |
bctx.fillStyle = 'rgba(255, 255, 144, 0.5)'; | |
const cellWidth = this.info.cellWidth; | |
const cellIndex = this._currentTime * 1000 / this.info.cellIntervalMs; | |
const pointerLeft = cellWidth * cellIndex - left - cellWidth / 2; | |
bctx.fillRect(pointerLeft, 0, cellWidth, height); | |
} | |
if (this.bufferCanvas.transferToImageBitmap && this.bitmapCtx && this.bitmapCtx.transferFromImageBitmap) { | |
const bitmap = this.bufferCanvas.transferToImageBitmap(); | |
this.bitmapCtx.transferFromImageBitmap(bitmap); | |
} else { | |
this.ctx.beginPath(); | |
this.ctx.drawImage(this.bufferCanvas, 0, 0, width, height,0, 0, width, height); | |
} | |
} | |
cls() { | |
this.bufferCtx.clearRect(0, 0, this.width, this.height); | |
this.ctx.clearRect(0, 0, this.width, this.height); | |
} | |
get currentTime() { | |
const center = this._scrollLeft + this.width / 2; | |
return this.duration * (center / this.totalWidth); | |
} | |
set currentTime(time) { this.setCurrentTime(time); } | |
get width() {return this.canvas.width;} | |
get height() {return this.canvas.height;} | |
set width(width) { | |
this.canvas.width = width; | |
this.bufferCanvas.width = width; | |
} | |
set height(height) { | |
this.canvas.height = height; | |
this.bufferCanvas.height = height; | |
} | |
setCurrentTime(sec) { | |
this._currentTime = sec; | |
const duration = Math.max(1, this.info.duration); | |
const per = sec / duration; | |
const intervalMs = this.info.cellIntervalMs; | |
const totalWidth = this.totalWidth; | |
const innerWidth = this.width; | |
const cellWidth = this.info.cellWidth; | |
const cellIndex = this._currentTime * 1000 / intervalMs; | |
const scrollLeft = Math.min(Math.max(cellWidth * cellIndex - innerWidth * per, 0), totalWidth - innerWidth); | |
this.scrollLeft = scrollLeft; | |
} | |
resize({width, height}) { | |
width && (this.width = width); | |
height && (this.height = height); | |
if (this.isReady) { | |
this.reDraw(); | |
} else { | |
this.cls(); | |
} | |
} | |
sharedMemory({buffer, MAP}) { | |
const view = new Float32Array(buffer); | |
const iview = new Int32Array(buffer); | |
this.buffer = { | |
get currentTime() { | |
return view[MAP.currentTime]; | |
}, | |
get timestamp() { | |
return iview[MAP.timestamp]; | |
}, | |
wait() { | |
const tm = Atomics.load(iview, MAP.timestamp); | |
Atomics.wait(iview, MAP.timestamp, tm, 3000); | |
return Atomics.load(iview, MAP.timestamp); | |
}, | |
get duration() { | |
return view[MAP.duration]; | |
}, | |
get playbackRate() { | |
return view[MAP.playbackRate]; | |
}, | |
get paused() { | |
return iview[MAP.paused] !== 0; | |
} | |
}; | |
} | |
async execAnimation() { // SharedArrayBufferで遊びたかっただけ. 最適化の余地はありそう | |
this.isAnimating = true; | |
const buffer = this.buffer; | |
while (this.isAnimating) { | |
while (!this.isReady) { | |
await new Promise(res => setTimeout(res, 500)); | |
} | |
while (this.isReady && this.isAnimating && !buffer.paused) { | |
buffer.wait(); | |
this.currentTime = this.buffer.currentTime; | |
await new Promise(res => requestAnimationFrame(res)); // 結局raf安定だった | |
} | |
if (!this.isAnimating) { return; } | |
await new Promise(res => setTimeout(res, 1000)); | |
} | |
} | |
startAnimation() { | |
if (!this.buffer || this.isAnimating) { return; } | |
this.currentTime = this.buffer.currentTime; | |
this.execAnimation(); | |
} | |
async stopAnimation() { | |
this.isAnimating = false; | |
await new Promise(res => requestAnimationFrame(res)); | |
this.reDraw(); | |
} | |
dispose() { | |
this.stopAnimation(); | |
this.isReady = false; | |
this.boards.length = 0; | |
} | |
} | |
class ThumbnailView { | |
constructor({canvas, info, name}) { | |
this.canvas = canvas; | |
this.name = name; | |
this._currentTime = -1; | |
this._info = new StoryboardInfoModel(info); | |
this.lastPos = {}; | |
this.ctx = canvas.getContext('2d', {alpha: false, desynchronized: true}); | |
this.images = ImageCacheMap; | |
this.cls(); | |
this.isInitialized = Promise.resolve(); | |
this.isAnimating = false; | |
} | |
get info() { return this._info; } | |
set info(info) { | |
this.isReady = false; | |
this.info && this.info.imageUrls.forEach(url => this.images.release(url)); | |
this._info.update(info); | |
this._currentTime = -1; | |
this.cls(); | |
if (!info.isAvailable) { | |
return; | |
} | |
this.isReady = true; | |
} | |
async setInfo(info) { | |
this.info = info; | |
} | |
cls() { | |
this.ctx.clearRect(0, 0, this.width, this.height); | |
} | |
get currentTime() { return this.currentTime; } | |
set currentTime(time) { this.setCurrentTime(time); } | |
get width() {return this.canvas.width;} | |
get height() {return this.canvas.height;} | |
set width(width) {this.canvas.width = width;} | |
set height(height) {this.canvas.height = height;} | |
async setCurrentTime(time) { | |
time > this.info.duration && (time = this.info.duration); | |
time < 0 && (time = 0); | |
if (this._currentTime === time) { | |
return; | |
} | |
const pos = this.info.getThumbnailPosition(time * 1000); | |
if (Object.keys(pos).every(key => pos[key] === this.lastPos[key])) { return; } | |
this.lastPos = pos; | |
this._currentTime = time; | |
const {url, row, col} = pos; | |
const cellWidth = this.info.cellWidth; | |
const cellHeight = this.info.cellHeight; | |
const image = await this.images.get(url); | |
const imageLeft = col * cellWidth; | |
const imageTop = row * cellHeight; | |
const scale = Math.min(this.width / cellWidth, this.height / cellHeight); | |
this.cls(); | |
this.ctx.drawImage( | |
image, | |
imageLeft, imageTop, cellWidth, cellHeight, | |
(this.width - cellWidth * scale) / 2, | |
(this.height - cellHeight * scale) / 2, | |
cellWidth * scale, cellHeight * scale | |
); | |
} | |
resize({width, height}) { | |
this.width = width; | |
this.height = height; | |
this.cls(); | |
} | |
dispose() { | |
this.info && this.info.imageUrls.forEach(url => this.images.release(url)); | |
this.info = null; | |
} | |
sharedMemory() {} | |
async execAnimation() { | |
while (this.isAnimating) { | |
while (!this.isReady) { | |
await new Promise(res => setTimeout(res, 500)); | |
} | |
await this.setCurrentTime((this.currentTime + this.info.interval / 1000) % this.info.duration); | |
if (!this.isAnimating) { return; } | |
await new Promise(res => setTimeout(res, 1000)); | |
} | |
} | |
startAnimation() { | |
if (this.isAnimating) { return; } | |
this.isAnimating = true; | |
this.execAnimation(); | |
} | |
stopAnimation() { | |
this.isAnimating = false; | |
} | |
} | |
const getId = function() {return `Storyboard-${this.id++}`;}.bind({id: 0}); | |
const createView = async ({canvas, info, name}, type = 'thumbnail') => { | |
const id = getId(); | |
const view = type === 'thumbnail' ? | |
new ThumbnailView({canvas, info, name}) : | |
new BoardView({canvas, info, name}); | |
items[id] = view; | |
await view.isInitialized; | |
return {status: 'ok', id}; | |
}; | |
const info = async ({id, info}) => { | |
const item = items[id]; | |
if (!item) { throw new Error(`unknown id:${id}`); } | |
await item.setInfo(info); | |
return {status: 'ok'}; | |
}; | |
const currentTime = ({id, currentTime}) => { | |
const item = items[id]; | |
if (!item) { throw new Error(`unknown id:${id}`); } | |
item.setCurrentTime(currentTime); | |
return {status: 'ok'}; | |
}; | |
const scrollLeft = ({id, scrollLeft}) => { | |
const item = items[id]; | |
if (!item) { throw new Error(`unknown id:${id}`); } | |
item.scrollLeft = scrollLeft; | |
return {status: 'ok'}; | |
}; | |
const resize = (params) => { | |
const item = items[params.id]; | |
if (!item) { throw new Error(`unknown id:${params.id}`); } | |
item.resize(params); | |
return {status: 'ok'}; | |
}; | |
const cls = (params) => { | |
const item = items[params.id]; | |
if (!item) { throw new Error(`unknown id:${params.id}`); } | |
item.cls(); | |
return {status: 'ok'}; | |
}; | |
const dispose = ({id}) => { | |
const item = items[id]; | |
if (!item) { return; } | |
item.dispose(); | |
delete items[id]; | |
return {status: 'ok'}; | |
}; | |
const sharedMemory = ({id, buffer, MAP}) => { | |
const item = items[id]; | |
if (!item) { throw new Error(`unknown id:${id}`); } | |
item.sharedMemory({buffer, MAP}); | |
return {status: 'ok'}; | |
}; | |
const startAnimation = ({id, interval}) => { | |
const item = items[id]; | |
if (!item) { throw new Error(`unknown id:${id}`); } | |
item.startAnimation(); | |
return {status: 'ok'}; | |
}; | |
const stopAnimation = ({id, interval}) => { | |
const item = items[id]; | |
if (!item) { throw new Error(`unknown id:${id}`); } | |
item.stopAnimation(); | |
return {status: 'ok'}; | |
}; | |
self.onmessage = async ({command, params}) => { | |
switch (command) { | |
case 'createThumbnail': | |
return createView(params, 'thumbnail'); | |
case 'createBoard': | |
return createView(params, 'board'); | |
case 'info': | |
return info(params); | |
case 'currentTime': | |
return currentTime(params); | |
case 'scrollLeft': | |
return scrollLeft(params); | |
case 'resize': | |
return resize(params); | |
case 'cls': | |
return cls(params); | |
case 'dispose': | |
return dispose(params); | |
case 'sharedMemory': | |
return sharedMemory(params); | |
case 'startAnimation': | |
return startAnimation(params); | |
case 'stopAnimation': | |
return stopAnimation(params); | |
} | |
}; | |
}; | |
const isOffscreenCanvasAvailable = !!HTMLCanvasElement.prototype.transferControlToOffscreen; | |
const NAME = 'StoryboardWorker'; | |
let worker; | |
const initWorker = async () => { | |
if (worker) { return worker; } | |
if (!isOffscreenCanvasAvailable) { | |
if (!worker) { | |
worker = { | |
name: NAME, | |
onmessage: () => {}, | |
post: ({command, params}) => worker.onmessage({command, params}) | |
}; | |
func(worker); | |
} | |
} else { | |
worker = worker || workerUtil.createCrossMessageWorker(func, {name: NAME}); | |
} | |
return worker; | |
}; | |
const createView = ({container, canvas, info, ratio, name, style}, type = 'thumbnail') => { | |
style = style || {}; | |
ratio = ratio || window.devicePixelRatio || 1; | |
name = name || 'Storyboard'; | |
if (!canvas) { | |
canvas = document.createElement('canvas'); | |
Object.assign(canvas.style, { | |
width: '100%', | |
height: '100%' | |
}); | |
container && container.append(canvas); | |
style.widthPx && (canvas.width = Math.max(style.widthPx)); | |
style.heightPx && (canvas.height = Math.max(style.heightPx)); | |
} | |
canvas.dataset.name = name; | |
canvas.classList.add('is-loading'); | |
const layer = isOffscreenCanvasAvailable ? canvas.transferControlToOffscreen() : canvas; | |
const promiseSetup = (async () => { | |
const worker = await initWorker(); | |
const result = await worker.post( | |
{command: | |
type === 'thumbnail' ? 'createThumbnail' : 'createBoard', | |
params: {canvas: layer, info, style, name}}, | |
{transfer: [layer]} | |
); | |
canvas.classList.remove('is-loading'); | |
return result.id; | |
})(); | |
let currentTime = -1, scrollLeft = -1, isAnimating = false; | |
const post = async ({command, params}, transfer = {}) => { | |
const id = await promiseSetup; | |
params = params || {}; | |
params.id = id; | |
return worker.post({command, params}, transfer); | |
}; | |
const result = { | |
container, | |
canvas, | |
setInfo(info) { | |
currentTime = -1; | |
scrollLeft = -1; | |
canvas.classList.add('is-loading'); | |
return post({command: 'info', params: {info}}) | |
.then(() => canvas.classList.remove('is-loading')); | |
}, | |
resize({width, height}) { | |
scrollLeft = -1; | |
return post({command: 'resize', params: {width, height}}); | |
}, | |
get scrollLeft() { | |
return scrollLeft; | |
}, | |
set scrollLeft(left) { | |
if (scrollLeft === left) { return; } | |
scrollLeft = left; | |
post({command: 'scrollLeft', params: {scrollLeft}}); | |
}, | |
get currentTime() { | |
return currentTime; | |
}, | |
set currentTime(time) { | |
if (currentTime === time) { return; } | |
currentTime = time; | |
post({command: 'currentTime', params: {currentTime}}); | |
}, | |
dispose() { | |
post({command: 'dispose', params: {}}); | |
}, | |
sharedMemory({MAP, buffer}) { | |
post({command: 'sharedMemory', params: {MAP, buffer}}); | |
}, | |
startAnimation() { | |
isAnimating = true; | |
post({command: 'startAnimation', params: {}}); | |
}, | |
stopAnimation() { | |
isAnimating = false; | |
post({command: 'stopAnimation', params: {}}); | |
}, | |
get isAnimating() { | |
return isAnimating; | |
} | |
}; | |
return result; | |
}; | |
return { | |
initWorker, | |
createThumbnail: args => createView(args, 'thumbnail'), | |
createBoard: args => createView(args, 'board') | |
}; | |
})(); | |
class Storyboard extends Emitter { | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
initialize(params) { | |
this.config = params.playerConfig; | |
this.container = params.container; | |
this.state = params.state; | |
this.loader = params.loader || StoryboardInfoLoader; | |
this.model = new StoryboardInfoModel({}); | |
global.debug.storyboard = this; | |
} | |
_initializeStoryboard() { | |
if (this.view) { | |
return; | |
} | |
this.view = new StoryboardView({ | |
model: this.model, | |
container: this.container, | |
enable: this.config.props.enableStoryboardBar, | |
state: this.state | |
}); | |
this.emitResolve('dom-ready'); | |
} | |
reset() { | |
if (!this.model) { return; } | |
this.state.isStoryboardAvailable = false; | |
this.model.reset(); | |
this.emit('reset', this.model); | |
} | |
onVideoCanPlay(watchId, videoInfo) { | |
if (!nicoUtil.isPremium()) { | |
return; | |
} | |
if (!this.config.props.enableStoryboard) { | |
return; | |
} | |
this._watchId = watchId; | |
const resuestId = this._requestId = Math.random(); | |
StoryboardInfoLoader.load(videoInfo) | |
.then(async (info) => { | |
await this.promise('dom-ready'); | |
return info; | |
}) | |
.then(this._onStoryboardInfoLoad.bind(this, resuestId)) | |
.catch(this._onStoryboardInfoLoadFail.bind(this, resuestId)); | |
this._initializeStoryboard(); | |
} | |
_onStoryboardInfoLoad(resuestId, rawData) { | |
if (resuestId !== this._requestId) {return;} // video changed | |
this.model.update(rawData); | |
this.emit('update', this.model); | |
this.state.isStoryboardAvailable = true; | |
} | |
_onStoryboardInfoLoadFail(resuestId, err) { | |
console.warn('onStoryboardInfoFail',this._watchId, err); | |
if (resuestId !== this._requestId) {return;} // video changed | |
this.model.update(null); | |
this.emit('update', this.model); | |
this.state.isStoryboardAvailable = false; | |
} | |
setCurrentTime(sec, forceUpdate) { | |
if (this.view && this.model.isAvailable) { | |
this.view.setCurrentTime(sec, forceUpdate); | |
} | |
} | |
set currentTime(sec) { | |
this.setCurrentTime(sec); | |
} | |
toggle() { | |
if (!this.view) { return; } | |
this.view.toggle(); | |
this.config.props.enableStoryboardBar = this.view.isEnable; | |
} | |
} | |
class VideoControlBar extends Emitter { | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
initialize(params) { | |
this._playerConfig = params.playerConfig; | |
this._$playerContainer = params.$playerContainer; | |
this._playerState = params.playerState; | |
this._currentTimeGetter = params.currentTimeGetter; | |
const player = this.player = params.player; | |
this.state = new VideoControlState(); | |
player.on('open', this._onPlayerOpen.bind(this)); | |
player.on('canPlay', this._onPlayerCanPlay.bind(this)); | |
player.on('durationChange', this._onPlayerDurationChange.bind(this)); | |
player.on('close', this._onPlayerClose.bind(this)); | |
player.on('progress', this._onPlayerProgress.bind(this)); | |
player.on('loadVideoInfo', this._onLoadVideoInfo.bind(this)); | |
player.on('commentParsed', _.debounce(this._onCommentParsed.bind(this), 500)); | |
player.on('commentChange', _.debounce(this._onCommentChange.bind(this), 100)); | |
Promise.all([ | |
player.promise('firstVideoInitialized'), this.promise('dom-ready') | |
]).then(() => this._onFirstVideoInitialized()); | |
this._initializeDom(); | |
this._initializePlaybackRateSelectMenu(); | |
this._initializeVolumeControl(); | |
this._initializeVideoServerTypeSelectMenu(); | |
global.debug.videoControlBar = this; | |
} | |
_initializeDom() { | |
const $view = this._$view = util.$.html(VideoControlBar.__tpl__); | |
const $container = this._$playerContainer; | |
const config = this._playerConfig; | |
this._view = $view[0]; | |
const classList = this.classList = ClassList(this._view); | |
const mq = $view.mapQuery({ | |
_seekBarContainer: '.seekBarContainer', | |
_seekBar: '.seekBar', | |
_currentTime: '.currentTime', | |
_duration: '.duration', | |
_playbackRateMenu: '.playbackRateMenu', | |
_playbackRateSelectMenu: '.playbackRateSelectMenu', | |
_videoServerTypeMenu: '.videoServerTypeMenu', | |
_videoServerTypeSelectMenu: '.videoServerTypeSelectMenu', | |
_resumePointer: 'zenza-seekbar-label', | |
_bufferRange: '.bufferRange', | |
_seekRange: '.seekRange', | |
_seekBarPointer: '.seekBarPointer', | |
resumePointers: 'zenza-seekbar-label', | |
}); | |
Object.assign(this, mq.e, {_currentTime: 0}); | |
Object.assign(this, mq.$); | |
util.$(this._seekRange) | |
.on('input', this._onSeekRangeInput.bind(this)) | |
.on('change', e => e.target.blur()); | |
this._pointer = new SmoothSeekBarPointer({ | |
pointer: this._seekBarPointer, | |
playerState: this._playerState | |
}); | |
const timeStyle = { | |
widthPx: 44, | |
heightPx: 18, | |
fontFamily: '\'Yu Gothic\', \'YuGothic\', \'Courier New\', Osaka-mono, \'MS ゴシック\', monospace', | |
fontWeight: '', | |
fontSizePx: 12, | |
color: '#fff' | |
}; | |
this.currentTimeLabel = TextLabel.create({ | |
container: $view.find('.currentTimeLabel')[0], | |
name: 'currentTimeLabel', | |
text: '--:--', | |
style: timeStyle | |
}); | |
this.durationLabel = TextLabel.create({ | |
container: $view.find('.durationLabel')[0], | |
name: 'durationLabel', | |
text: '--:--', | |
style: timeStyle | |
}); | |
this._$seekBar | |
.on('mousedown', this._onSeekBarMouseDown.bind(this)) | |
.on('mousemove', this._onSeekBarMouseMove.bind(this)); | |
$view | |
.on('click', this._onClick.bind(this)) | |
.on('command', this._onCommandEvent.bind(this)); | |
HeatMapWorker.init({container: this._seekBar}).then(hm => this.heatMap = hm); | |
const updateHeatMapVisibility = | |
v => this._$seekBarContainer.raf.toggleClass('noHeatMap', !v); | |
updateHeatMapVisibility(this._playerConfig.props.enableHeatMap); | |
this._playerConfig.onkey('enableHeatMap', updateHeatMapVisibility); | |
global.emitter.on('heatMapUpdate', | |
heatMap => WatchInfoCacheDb.put(this.player.watchId, {heatMap})); | |
this.storyboard = new Storyboard({ | |
playerConfig: config, | |
player: this.player, | |
state: this.state, | |
container: $view[0] | |
}); | |
this.state.onkey('isStoryboardAvailable', | |
v => classList.toggle('is-storyboardAvailable', v)); | |
this._seekBarToolTip = new SeekBarToolTip({ | |
$container: this._$seekBarContainer, | |
storyboard: this.storyboard | |
}); | |
this._commentPreview = new CommentPreview({ | |
$container: this._$seekBarContainer | |
}); | |
const updateEnableCommentPreview = v => { | |
this._$seekBarContainer.raf.toggleClass('enableCommentPreview', v); | |
this._commentPreview.mode = v ? 'list' : 'hover'; | |
}; | |
updateEnableCommentPreview(config.props.enableCommentPreview); | |
config.onkey('enableCommentPreview', updateEnableCommentPreview); | |
const watchElement = $container[0].closest('#zenzaVideoPlayerDialog'); | |
this._wheelSeeker = new WheelSeeker({ | |
parentNode: $view[0], | |
watchElement | |
}); | |
watchElement.addEventListener('mousedown', e => { | |
if (['A', 'INPUT', 'TEXTAREA'].includes(e.target.tagName)) { | |
return; | |
} | |
if (e.buttons !== 3 && !(e.button === 0 && e.shiftKey)) { | |
return; | |
} | |
if (e.buttons === 3) { | |
watchElement.addEventListener('contextmenu', e => { | |
window.console.log('contextmenu', e); | |
e.preventDefault(); | |
e.stopPropagation(); | |
}, {once: true, capture: true}); | |
} | |
this._onSeekBarMouseDown(e); | |
}); | |
global.emitter.on('hideHover', () => { | |
this._hideMenu(); | |
this._commentPreview.hide(); | |
}); | |
$container.append($view); | |
this.emitResolve('dom-ready'); | |
} | |
_initializePlaybackRateSelectMenu() { | |
const config = this._playerConfig; | |
const $menu = this._$playbackRateSelectMenu; | |
const $rates = $menu.find('.playbackRate'); | |
const style = { | |
widthPx: 48, | |
heightPx: 30, | |
fontFamily: '"ヒラギノ角ゴ Pro W3", "Hiragino Kaku Gothic Pro", "メイリオ", Meiryo, Osaka, "MS Pゴシック", "MS PGothic", sans-serif', | |
fontWeight: '', | |
fontSizePx: 18, | |
color: '#fff' | |
}; | |
const rateLabel = TextLabel.create({ | |
container: this._$playbackRateMenu.find('.controlButtonInner')[0], | |
name: 'currentTimeLabel', | |
text: '', | |
style | |
}); | |
const updatePlaybackRate = rate => { | |
rateLabel.text = `x${Math.round(rate * 100) / 100}`; | |
$menu.find('.selected').removeClass('selected'); | |
const fr = Math.floor( parseFloat(rate, 10) * 100) / 100; | |
$rates.forEach(item => { | |
const r = parseFloat(item.dataset.param, 10); | |
if (fr === r) { | |
ClassList(item).add('selected'); | |
} | |
}); | |
this._pointer.playbackRate = rate; | |
}; | |
updatePlaybackRate(config.props.playbackRate); | |
config.onkey('playbackRate', updatePlaybackRate); | |
} | |
_initializeVolumeControl() { | |
const $vol = this._$view.find('zenza-range-bar input[type="range"]'); | |
const [vol] = $vol; | |
const setVolumeBar = this._setVolumeBar = v => (vol.view || vol).value = v; | |
$vol.on('input', e => util.dispatchCommand(e.target, 'volume', e.target.value)); | |
setVolumeBar(this._playerConfig.props.volume); | |
this._playerConfig.onkey('volume', setVolumeBar); | |
} | |
_initializeVideoServerTypeSelectMenu() { | |
const config = this._playerConfig; | |
const $button = this._$videoServerTypeMenu; | |
const $select = this._$videoServerTypeSelectMenu; | |
const $current = $select.find('.currentVideoQuality'); | |
const updateSmileVideoQuality = value => { | |
const $dq = $select.find('.smileVideoQuality'); | |
$dq.removeClass('selected'); | |
$select.find('.select-smile-' + (value === 'eco' ? 'economy' : 'default')).addClass('selected'); | |
}; | |
const updateDmcVideoQuality = value => { | |
const $dq = $select.find('.dmcVideoQuality'); | |
$dq.removeClass('selected'); | |
$select.find('.select-dmc-' + value).addClass('selected'); | |
}; | |
const onVideoServerType = (type, videoSessionInfo) => { | |
$button.raf.removeClass('is-smile-playing is-dmc-playing') | |
.raf.addClass(`is-${type === 'dmc' ? 'dmc' : 'smile'}-playing`); | |
$select.find('.serverType').removeClass('selected'); | |
$select.find(`.select-server-${type === 'dmc' ? 'dmc' : 'smile'}`).addClass('selected'); | |
$current.raf.text(type !== 'dmc' ? '----' : videoSessionInfo.videoFormat.replace(/^.*h264_/, '')); | |
}; | |
updateSmileVideoQuality(config.props.smileVideoQuality); | |
updateDmcVideoQuality(config.props.dmcVideoQuality); | |
config.onkey('forceEconomy', updateSmileVideoQuality); | |
config.onkey('dmcVideoQuality', updateDmcVideoQuality); | |
this.player.on('videoServerType', onVideoServerType); | |
} | |
_onCommandEvent(e) { | |
const command = e.detail.command; | |
switch (command) { | |
case 'toggleStoryboard': | |
this.storyboard.toggle(); | |
break; | |
case 'wheelSeek-start': | |
window.console.log('start-seek-start'); | |
this.state.isWheelSeeking = true; | |
this._wheelSeeker.currentTime = this.player.currentTime; | |
this.classList.add('is-wheelSeeking'); | |
break; | |
case 'wheelSeek-end': | |
window.console.log('start-seek-end'); | |
this.state.isWheelSeeking = false; | |
this.classList.remove('is-wheelSeeking'); | |
break; | |
case 'wheelSeek': | |
this._onWheelSeek(e.detail.param); | |
break; | |
default: | |
return; | |
} | |
e.stopPropagation(); | |
} | |
_onClick(e) { | |
e.preventDefault(); | |
const target = e.target.closest('[data-command]'); | |
if (!target) { | |
return; | |
} | |
let {command, param, type} = target.dataset; | |
if (param && (type === 'bool' || type === 'json')) { | |
param = JSON.parse(param); | |
} | |
switch (command) { | |
case 'toggleStoryboard': | |
this.storyboard.toggle(); | |
break; | |
default: | |
util.dispatchCommand(target, command, param); | |
break; | |
} | |
e.stopPropagation(); | |
} | |
_posToTime(pos) { | |
const width = global.innerWidth; | |
return this._duration * (pos / Math.max(width, 1)); | |
} | |
_timeToPos(time) { | |
return global.innerWidth * (time / Math.max(this._duration, 1)); | |
} | |
_timeToPer(time) { | |
return (time / Math.max(this._duration, 1)) * 100; | |
} | |
_onPlayerOpen() { | |
this._startTimer(); | |
this.duration = 0; | |
this.currentTime = 0; | |
this.heatMap && this.heatMap.reset(); | |
this.storyboard.reset(); | |
this.resetBufferedRange(); | |
} | |
_onPlayerCanPlay(watchId, videoInfo) { | |
const duration = this.player.duration; | |
this.duration = duration; | |
this.storyboard.onVideoCanPlay(watchId, videoInfo); | |
this.heatMap && (this.heatMap.duration = duration); | |
} | |
_onCommentParsed() { | |
const chatList = this.player.chatList; | |
this.heatMap && (this.heatMap.chatList = chatList); | |
this._commentPreview.chatList = chatList; | |
} | |
_onCommentChange() { | |
const chatList = this.player.chatList; | |
this.heatMap && (this.heatMap.chatList = chatList); | |
this._commentPreview.chatList = chatList; | |
} | |
_onPlayerDurationChange() { | |
this._pointer.duration = this._playerState.videoInfo.duration; | |
this._wheelSeeker.duration = this._playerState.videoInfo.duration; | |
this.heatMap && (this.heatMap.chatList = this.player.chatList); | |
} | |
_onPlayerClose() { | |
this._stopTimer(); | |
} | |
_onPlayerProgress(range, currentTime) { | |
this.setBufferedRange(range, currentTime); | |
} | |
_startTimer() { | |
this._timerCount = 0; | |
this._raf = this._raf || new RequestAnimationFrame(this._onTimer.bind(this)); | |
this._raf.enable(); | |
} | |
_stopTimer() { | |
this._raf && this._raf.disable(); | |
} | |
_onSeekRangeInput(e) { | |
const sec = e.target.value * 1; | |
const left = sec / (e.target.max * 1) * global.innerWidth; | |
util.dispatchCommand(e.target, 'seek', sec); | |
this._seekBarToolTip.update(sec, left); | |
this.storyboard.setCurrentTime(sec, true); | |
} | |
_onSeekBarMouseDown(e) { | |
e.stopPropagation(); | |
this._beginMouseDrag(e); | |
} | |
_onSeekBarMouseMove(e) { | |
if (!this.state.isDragging) { | |
e.stopPropagation(); | |
} | |
const left = e.offsetX; | |
const sec = this._posToTime(left); | |
this._seekBarMouseX = left; | |
this._commentPreview.currentTime = sec; | |
this._commentPreview.update(left); | |
this._seekBarToolTip.update(sec, left); | |
} | |
_onWheelSeek(sec) { | |
if (!this.state.isWheelSeeking) { | |
return; | |
} | |
sec = sec * 1; | |
const dur = this._duration; | |
const left = sec / dur * window.innerWidth; | |
this._seekBarMouseX = left; | |
this._commentPreview.currentTime = sec; | |
this._commentPreview.update(left); | |
this._seekBarToolTip.update(sec, left); | |
this.storyboard.setCurrentTime(sec, true); | |
} | |
_beginMouseDrag() { | |
this._bindDragEvent(); | |
this.classList.add('is-dragging'); | |
this.state.isDragging = true; | |
} | |
_endMouseDrag() { | |
this._unbindDragEvent(); | |
this.classList.remove('is-dragging'); | |
this.state.isDragging = false; | |
} | |
_onBodyMouseUp(e) { | |
if ((e.button === 0 && e.shiftKey)) { | |
return; | |
} | |
this._endMouseDrag(); | |
} | |
_onWindowBlur() { | |
this._endMouseDrag(); | |
} | |
_bindDragEvent() { | |
util.$('body') | |
.on('mouseup.ZenzaWatchSeekBar', this._onBodyMouseUp.bind(this)); | |
util.$(window).on('blur.ZenzaWatchSeekBar', this._onWindowBlur.bind(this), {once: true}); | |
} | |
_unbindDragEvent() { | |
util.$('body') | |
.off('mouseup.ZenzaWatchSeekBar'); | |
util.$(window).off('blur.ZenzaWatchSeekBar'); | |
} | |
_onTimer() { | |
this._timerCount++; | |
const player = this.player; | |
const currentTime = this.state.isWheelSeeking ? | |
this._wheelSeeker.currentTime : player.currentTime; | |
if (this._timerCount % 6 === 0) { | |
this.currentTime = currentTime; | |
} | |
this.storyboard.currentTime = currentTime; | |
} | |
_onLoadVideoInfo(videoInfo) { | |
this.duration = videoInfo.duration; | |
const [view] = this._$view; | |
const resumePoints = videoInfo.resumePoints; | |
for (let i = 0, len = this.$resumePointers.length; i < len; i++) { | |
const pointer = this.$resumePointers[i]; | |
const resume = resumePoints[i]; | |
if (!resume) { | |
pointer.hidden = true; | |
continue; | |
} | |
pointer.setAttribute('duration', videoInfo.duration); | |
pointer.setAttribute('time', resume.time); | |
pointer.setAttribute('text', `${resume.now} ここまで見た`); | |
if (i > 0) { | |
cssUtil.setProps( | |
[pointer, '--pointer-color', 'rgba(128, 128, 255, 0.6)'], | |
[pointer, '--color', '#aef']); | |
} else{ | |
cssUtil.setProps([pointer, '--scale-pp', 1.7]); | |
} | |
} | |
} | |
async _onFirstVideoInitialized(watchId) { | |
const [view] = this._$view; | |
const handler = (command, param) => this.emit('command', command, param); | |
const ge = global.emitter; // emitAsync は互換用に残してる | |
ge.emitResolve('videoControBar.addonMenuReady', | |
{container: view.querySelector('.controlItemContainer.left .scalingUI'), handler} | |
).then(({container, handler}) => ge.emitAsync('videoControBar.addonMenuReady', container, handler)); | |
ge.emitResolve('seekBar.addonMenuReady', | |
{container: view.querySelector('.seekBar'), handler} | |
).then(({container, handle}) => ge.emitAsync('seekBar.addonMenuReady', container, handle)); | |
} | |
get currentTime() { | |
return this._currentTime; | |
} | |
setCurrentTime(sec) { | |
this.currentTime = sec; | |
} | |
set currentTime(sec) { | |
if (this._currentTime === sec) { return; } | |
this._currentTime = sec; | |
const currentTimeText = util.secToTime(sec); | |
if (this._currentTimeText !== currentTimeText) { | |
this._currentTimeText = currentTimeText; | |
this.currentTimeLabel.text = currentTimeText; | |
} | |
this._pointer.currentTime = sec; | |
} | |
get duration() { | |
return this._duration; | |
} | |
set duration(sec) { | |
if (sec === this._duration) { return; } | |
this._duration = sec; | |
this._pointer.currentTime = -1; | |
this._pointer.duration = sec; | |
this._wheelSeeker.duration = sec; | |
this._seekRange.max = sec; | |
if (sec === 0 || isNaN(sec)) { | |
this.durationLabel.text = '--:--'; | |
} else { | |
this.durationLabel.text = util.secToTime(sec); | |
} | |
this.emit('durationChange'); | |
} | |
setBufferedRange(range, currentTime) { | |
const bufferRange = this._bufferRange; | |
if (!range || !range.length || !this._duration) { | |
return; | |
} | |
for (let i = 0, len = range.length; i < len; i++) { | |
try { | |
const start = range.start(i); | |
const end = range.end(i); | |
const width = end - start; | |
if (start <= currentTime && end >= currentTime) { | |
if (this._bufferStart !== start || | |
this._bufferEnd !== end) { | |
const perLeft = (this._timeToPer(start) - 1); | |
const scaleX = (this._timeToPer(width) + 2) / 100; | |
cssUtil.setProps( | |
[bufferRange, '--buffer-range-left', cssUtil.percent(perLeft)], | |
[bufferRange, '--buffer-range-scale', scaleX] | |
); | |
this._bufferStart = start; | |
this._bufferEnd = end; | |
} | |
break; | |
} | |
} catch (e) {} | |
} | |
} | |
resetBufferedRange() { | |
this._bufferStart = 0; | |
this._bufferEnd = 0; | |
cssUtil.setProps([this._bufferRange, '--buffer-range-scale', 0]); | |
} | |
_hideMenu() { | |
document.body.focus(); | |
} | |
} | |
VideoControlBar.BASE_HEIGHT = CONSTANT.CONTROL_BAR_HEIGHT; | |
VideoControlBar.BASE_SEEKBAR_HEIGHT = 10; | |
util.addStyle(` | |
.videoControlBar { | |
position: fixed; | |
bottom: 0; | |
left: 0; | |
width: 100vw; | |
height: var(--zenza-control-bar-height, ${VideoControlBar.BASE_HEIGHT}px); | |
z-index: 150000; | |
background: #000; | |
transition: opacity 0.3s ease, transform 0.3s ease; | |
user-select: none; | |
contain: layout style size; | |
will-change: transform; | |
} | |
.videoControlBar * { | |
box-sizing: border-box; | |
user-select: none; | |
} | |
.videoControlBar.is-wheelSeeking { | |
pointer-events: none; | |
} | |
.controlItemContainer { | |
position: absolute; | |
top: 10px; | |
height: 40px; | |
z-index: 200; | |
} | |
.controlItemContainer:hover, | |
.controlItemContainer:focus-within, | |
.videoControlBar.is-menuOpen .controlItemContainer { | |
z-index: 260; | |
} | |
.controlItemContainer.left { | |
left: 0; | |
height: 40px; | |
white-space: nowrap; | |
overflow: visible; | |
transition: transform 0.2s ease, left 0.2s ease; | |
} | |
.controlItemContainer.left .scalingUI { | |
padding: 0 8px 0; | |
} | |
.controlItemContainer.left .scalingUI:empty { | |
display: none; | |
} | |
.controlItemContainer.left .scalingUI>* { | |
background: #222; | |
display: inline-block; | |
} | |
.controlItemContainer.center { | |
left: 50%; | |
height: 40px; | |
transform: translate(-50%, 0); | |
white-space: nowrap; | |
overflow: visible; | |
transition: transform 0.2s ease, left 0.2s ease; | |
} | |
.controlItemContainer.center .scalingUI { | |
transform-origin: top center; | |
} | |
.controlItemContainer.center .scalingUI > div{ | |
display: flex; | |
align-items: center; | |
background: | |
linear-gradient(to bottom, | |
transparent, transparent 4px, #222 0, #222 30px, transparent 0, transparent); | |
height: 32px; | |
} | |
.controlItemContainer.right { | |
right: 0; | |
} | |
.is-mouseMoving .controlItemContainer.right .controlButton{ | |
background: #333; | |
} | |
.controlItemContainer.right .scalingUI { | |
transform-origin: top right; | |
} | |
.controlButton { | |
position: relative; | |
display: inline-block; | |
transition: opacity 0.4s ease; | |
font-size: 20px; | |
width: 32px; | |
height: 32px; | |
line-height: 30px; | |
box-sizing: border-box; | |
text-align: center; | |
cursor: pointer; | |
color: #fff; | |
opacity: 0.8; | |
min-width: 32px; | |
vertical-align: middle; | |
outline: none; | |
} | |
.controlButton:hover { | |
cursor: pointer; | |
opacity: 1; | |
} | |
.controlButton:active .controlButtonInner { | |
transform: translate(0, 2px); | |
} | |
.is-abort .playControl, | |
.is-error .playControl, | |
.is-loading .playControl { | |
opacity: 0.4 !important; | |
pointer-events: none; | |
} | |
.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; | |
} | |
.is-mouseMoving .controlButton:hover .tooltip { | |
display: block; | |
opacity: 1; | |
} | |
.videoControlBar:hover .controlButton { | |
opacity: 1; | |
pointer-events: auto; | |
} | |
.videoControlBar .controlButton:focus-within { | |
pointer-events: none; | |
} | |
.videoControlBar .controlButton:focus-within .zenzaPopupMenu, | |
.videoControlBar .controlButton .zenzaPopupMenu:hover { | |
pointer-events: auto; | |
visibility: visible; | |
opacity: 0.99; | |
pointer-events: auto; | |
transition: opacity 0.3s; | |
} | |
.videoControlBar .controlButton:focus-within .tooltip { | |
display: none; | |
} | |
.settingPanelSwitch { | |
width: 32px; | |
} | |
.settingPanelSwitch:hover { | |
text-shadow: 0 0 8px #ff9; | |
} | |
.settingPanelSwitch .tooltip { | |
left: 0; | |
} | |
.videoControlBar .zenzaSubMenu { | |
left: 50%; | |
transform: translate(-50%, 0); | |
bottom: 44px; | |
white-space: nowrap; | |
} | |
.videoControlBar .triangle { | |
transform: translate(-50%, 0) rotate(-45deg); | |
bottom: -8.5px; | |
left: 50%; | |
} | |
.videoControlBar .zenzaSubMenu::after { | |
content: ''; | |
position: absolute; | |
display: block; | |
width: 110%; | |
height: 16px; | |
left: -5%; | |
} | |
.controlButtonInner { | |
display: inline-block; | |
} | |
.seekTop { | |
left: 0px; | |
width: 32px; | |
transform: scale(1.1); | |
} | |
.togglePlay { | |
width: 36px; | |
transition: transform 0.2s ease; | |
transform: scale(1.1); | |
} | |
.togglePlay:active { | |
transform: scale(0.75); | |
} | |
.togglePlay .play, | |
.togglePlay .pause { | |
display: inline-block; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transition: transform 0.1s linear, opacity 0.1s linear; | |
user-select: none; | |
pointer-events: none; | |
} | |
.togglePlay .play { | |
width: 100%; | |
height: 100%; | |
transform: scale(1.2) translate(-50%, -50%) translate(10%, 10%); | |
} | |
.is-playing .togglePlay .play { | |
opacity: 0; | |
} | |
.togglePlay>.pause { | |
width: 24px; | |
height: 16px; | |
background-image: linear-gradient( | |
to right, | |
transparent 0, transparent 12.5%, | |
currentColor 0, currentColor 43.75%, | |
transparent 0, transparent 56.25%, | |
currentColor 0, currentColor 87.5%, | |
transparent 0); | |
opacity: 0; | |
transform: scaleX(0); | |
} | |
.is-playing .togglePlay>.pause { | |
opacity: 1; | |
transform: translate(-50%, -50%); | |
} | |
.seekBarContainer { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
cursor: pointer; | |
z-index: 250; | |
} | |
/* 見えないマウス判定 */ | |
.seekBarContainer .seekBarShadow { | |
position: absolute; | |
background: transparent; | |
opacity: 0; | |
width: 100vw; | |
height: 8px; | |
top: -8px; | |
} | |
.is-mouseMoving .seekBarContainer:hover .seekBarShadow { | |
height: 48px; | |
top: -48px; | |
} | |
.is-abort .seekBarContainer, | |
.is-loading .seekBarContainer, | |
.is-error .seekBarContainer { | |
pointer-events: none; | |
} | |
.is-abort .seekBarContainer *, | |
.is-error .seekBarContainer * { | |
display: none; | |
} | |
.seekBar { | |
position: relative; | |
width: 100%; | |
height: 10px; | |
margin: 2px 0 2px; | |
border-top: 1px solid #333; | |
border-bottom: 1px solid #333; | |
cursor: pointer; | |
transition: height 0.2s ease 1s, margin-top 0.2s ease 1s; | |
} | |
.seekBar:hover { | |
height: 24px; | |
/* このmargin-topは見えないマウスオーバー判定を含む */ | |
margin-top: -14px; | |
transition: none; | |
background-color: rgba(0, 0, 0, 0.5); | |
} | |
.seekBarContainer .seekBar * { | |
pointer-events: none; | |
} | |
.bufferRange { | |
position: absolute; | |
--buffer-range-left: 0; | |
--buffer-range-scale: 0; | |
width: 100%; | |
height: 110%; | |
left: 0px; | |
top: 0px; | |
box-shadow: 0 0 6px #ff9 inset, 0 0 4px #ff9; | |
z-index: 190; | |
background: #ff9; | |
transform-origin: left; | |
transform: | |
translateX(var(--buffer-range-left)) | |
scaleX(var(--buffer-range-scale)); | |
transition: transform 0.2s; | |
mix-blend-mode: overlay; | |
will-change: transform, opacity; | |
opacity: 0.6; | |
} | |
.is-youTube .bufferRange { | |
width: 100% !important; | |
height: 110% !important; | |
background: #f99; | |
transition: transform 0.5s ease 1s; | |
transform: translate3d(0, 0, 0) scaleX(1) !important; | |
} | |
.seekBarPointer { | |
/*--width-pp: 12px; | |
--trans-x-pp: 0;*/ | |
position: absolute; | |
display: inline-block; | |
top: -1px; | |
left: 0; | |
width: 12px; | |
background: rgba(255, 255, 255, 0.7); | |
height: calc(100% + 2px); | |
z-index: 200; | |
box-shadow: 0 0 4px #ffc inset; | |
pointer-events: none; | |
transform: translateX(-6px); | |
/*transform: translate(calc(var(--trans-x-pp) - var(--width-pp) / 2), -50%);*/ | |
will-change: transform; | |
mix-blend-mode: lighten; | |
} | |
.is-loading .seekBarPointer { | |
display: none !important; | |
} | |
.is-dragging .seekBarPointer.is-notSmooth { | |
transition: none; | |
} | |
.is-dragging .seekBarPointer::after, | |
.is-wheelSeeking .seekBarPointer::after { | |
content: ''; | |
position: absolute; | |
width: 36px; | |
height: 36px; | |
left: 50%; | |
top: 50%; | |
transform: translate(-50%, -50%); | |
border-radius: 100%; | |
box-shadow: 0 0 8px #ffc inset, 0 0 8px #ffc; | |
pointer-events: none; | |
} | |
.seekBarContainer .seekBar .seekRange { | |
-webkit-appearance: none; | |
position: absolute; | |
width: 100vw; | |
height: 100%; | |
cursor: pointer; | |
opacity: 0; | |
pointer-events: auto; | |
} | |
.seekRange::-webkit-slider-thumb { | |
-webkit-appearance: none; | |
height: 10px; | |
width: 2px; | |
} | |
.seekRange::-moz-range-thumb { | |
height: 10px; | |
width: 2px; | |
} | |
.videoControlBar .videoTime { | |
display: inline-flex; | |
top: 0; | |
padding: 0; | |
width: 96px; | |
height: 18px; | |
line-height: 18px; | |
contain: strict; | |
color: #fff; | |
font-size: 12px; | |
white-space: nowrap; | |
vertical-align: middle; | |
background: rgba(33, 33, 33, 0.5); | |
border: 0; | |
pointer-events: none; | |
user-select: none; | |
} | |
.videoControlBar .videoTime .currentTimeLabel, | |
.videoControlBar .videoTime .currentTime, | |
.videoControlBar .videoTime .duration { | |
position: relative; | |
display: inline-block; | |
color: #fff; | |
text-align: center; | |
background: inherit; | |
border: 0; | |
width: 44px; | |
font-family: 'Yu Gothic', 'YuGothic', 'Courier New', Osaka-mono, 'MS ゴシック', monospace; | |
} | |
.videoControlBar.is-loading .videoTime { | |
display: none; | |
} | |
.seekBarContainer .tooltip { | |
position: absolute; | |
padding: 1px; | |
bottom: 12px; | |
left: 0; | |
transform: translate(-50%, 0); | |
white-space: nowrap; | |
font-size: 10px; | |
opacity: 0; | |
border: 1px solid #000; | |
background: #fff; | |
color: #000; | |
z-index: 150; | |
} | |
.is-dragging .seekBarContainer .tooltip, | |
.seekBarContainer:hover .tooltip { | |
opacity: 0.8; | |
} | |
.resumePointer { | |
position: absolute; | |
mix-blend-mode: color-dodge; | |
will-change: transform; | |
top: 0; | |
z-index: 200; | |
} | |
.zenzaHeatMap { | |
position: absolute; | |
pointer-events: none; | |
top: 0; left: 0; | |
width: 100%; | |
height: 100%; | |
transform-origin: 0 0 0; | |
will-change: transform; | |
opacity: 0.5; | |
z-index: 110; | |
} | |
.noHeatMap .zenzaHeatMap { | |
display: none; | |
} | |
.loopSwitch { | |
width: 32px; | |
height: 32px; | |
line-height: 30px; | |
font-size: 20px; | |
color: #888; | |
} | |
.loopSwitch:active { | |
font-size: 15px; | |
} | |
.is-loop .loopSwitch { | |
color: var(--enabled-button-color); | |
} | |
.loopSwitch .controlButtonInner { | |
font-family: STIXGeneral; | |
} | |
.playbackRateMenu { | |
bottom: 0; | |
width: auto; | |
width: 48px; | |
height: 32px; | |
line-height: 30px; | |
font-size: 18px; | |
white-space: nowrap; | |
margin-right: 0; | |
} | |
.playbackRateSelectMenu { | |
width: 180px; | |
text-align: left; | |
line-height: 20px; | |
font-size: 18px !important; | |
} | |
.playbackRateSelectMenu ul { | |
margin: 2px 8px; | |
} | |
.playbackRateSelectMenu li { | |
padding: 3px 4px; | |
} | |
.screenModeMenu { | |
width: 32px; | |
height: 32px; | |
line-height: 30px; | |
font-size: 20px; | |
} | |
.screenModeMenu:active { | |
font-size: 15px; | |
} | |
.screenModeMenu:focus-within { | |
background: #888; | |
} | |
.screenModeMenu:focus-within .tooltip { | |
display: none; | |
} | |
.screenModeMenu:active { | |
font-size: 10px; | |
} | |
.screenModeSelectMenu { | |
width: 148px; | |
padding: 2px 4px; | |
font-size: 12px; | |
line-height: 15px; | |
} | |
.screenModeSelectMenu ul { | |
display: grid; | |
grid-template-columns: 1fr 1fr; | |
} | |
.screenModeSelectMenu ul li { | |
display: inline-block; | |
text-align: center; | |
border: none !important; | |
margin: 0 !important; | |
padding: 0 !important; | |
} | |
.screenModeSelectMenu ul li span { | |
border: 1px solid #ccc; | |
width: 50px; | |
margin: 2px 8px; | |
padding: 4px 0; | |
} | |
body[data-screen-mode="3D"] .screenModeSelectMenu li.mode3D span, | |
body[data-screen-mode="sideView"] .screenModeSelectMenu li.sideView span, | |
body[data-screen-mode="small"] .screenModeSelectMenu li.small span, | |
body[data-screen-mode="normal"] .screenModeSelectMenu li.normal span, | |
body[data-screen-mode="big"] .screenModeSelectMenu li.big span, | |
body[data-screen-mode="wide"] .screenModeSelectMenu li.wide span { | |
color: #ff9; | |
border-color: #ff0; | |
} | |
.fullscreenControlBarModeMenu { | |
display: none; | |
} | |
.fullscreenControlBarModeMenu .controlButtonInner { | |
filter: grayscale(100%); | |
} | |
.fullscreenControlBarModeMenu:focus-within .controlButtonInner, | |
.fullscreenControlBarModeMenu:hover .controlButtonInner { | |
filter: grayscale(50%); | |
} | |
.is-fullscreen .fullscreenSwitch .controlButtonInner .toFull, | |
body:not(.is-fullscreen) .fullscreenSwitch .controlButtonInner .returnFull { | |
display: none; | |
} | |
.videoControlBar .muteSwitch { | |
margin-right: 0; | |
} | |
.videoControlBar .muteSwitch:active { | |
font-size: 15px; | |
} | |
.zenzaPlayerContainer:not(.is-mute) .muteSwitch .mute-on, | |
.is-mute .muteSwitch .mute-off { | |
display: none; | |
} | |
.videoControlBar .volumeControl { | |
display: inline-block; | |
} | |
.videoControlBar .volumeRange { | |
width: 64px; | |
height: 8px; | |
position: relative; | |
vertical-align: middle; | |
--back-color: #333; | |
--fore-color: #ccc; | |
} | |
.is-mute .videoControlBar .volumeRange { | |
--fore-color: var(--back-color); | |
pointer-events: none; | |
} | |
.prevVideo.playControl, | |
.nextVideo.playControl { | |
display: none; | |
} | |
.is-playlistEnable .prevVideo.playControl, | |
.is-playlistEnable .nextVideo.playControl { | |
display: inline-block; | |
} | |
.prevVideo, | |
.nextVideo { | |
font-size: 23px; | |
} | |
.prevVideo .controlButtonInner { | |
transform: scaleX(-1); | |
} | |
.toggleStoryboard { | |
visibility: hidden; | |
pointer-events: none; | |
} | |
.is-storyboardAvailable .toggleStoryboard { | |
visibility: visible; | |
pointer-events: auto; | |
} | |
.zenzaStoryboardOpen .is-storyboardAvailable .toggleStoryboard { | |
color: var(--enabled-button-color); | |
} | |
.toggleStoryboard .controlButtonInner { | |
position: absolute; | |
width: 20px; | |
height: 20px; | |
top: 50%; | |
left: 50%; | |
border-radius: 75% 16%; | |
border: 1px solid; | |
transform: translate(-50%, -50%) rotate(45deg); | |
pointer-events: none; | |
background: | |
radial-gradient( | |
currentColor, | |
currentColor 6px, | |
transparent 0 | |
); | |
} | |
.toggleStoryboard:active .controlButtonInner { | |
transform: translate(-50%, -50%) scaleY(0.1) rotate(45deg); | |
} | |
.toggleStoryboard:active { | |
transform: scale(0.75); | |
} | |
.videoServerTypeMenu { | |
bottom: 0; | |
min-width: 40px; | |
height: 32px; | |
line-height: 30px; | |
font-size: 16px; | |
white-space: nowrap; | |
} | |
.videoServerTypeMenu.is-dmc-playing { | |
text-shadow: | |
0px 0px 8px var(--enabled-button-color), | |
0px 0px 6px var(--enabled-button-color), | |
0px 0px 4px var(--enabled-button-color), | |
0px 0px 2px var(--enabled-button-color); | |
} | |
.is-mouseMoving .videoServerTypeMenu.is-dmc-playing { | |
background: #336; | |
} | |
.is-youTube .videoServerTypeMenu { | |
text-shadow: | |
0px 0px 8px #fc9, 0px 0px 6px #fc9, 0px 0px 4px #fc9, 0px 0px 2px #fc9 !important; | |
} | |
.is-youTube .videoServerTypeMenu:not(.forYouTube), | |
.videoServerTypeMenu.forYouTube { | |
display: none; | |
} | |
.is-youTube .videoServerTypeMenu.forYouTube { | |
display: inline-block; | |
} | |
.videoServerTypeMenu:active { | |
font-size: 13px; | |
} | |
.videoServerTypeMenu:focus-within { | |
background: #888; | |
} | |
.videoServerTypeMenu:focus-within .tooltip { | |
display: none; | |
} | |
.videoServerTypeSelectMenu { | |
bottom: 44px; | |
left: 50%; | |
transform: translate(-50%, 0); | |
width: 180px; | |
text-align: left; | |
line-height: 20px; | |
font-size: 16px !important; | |
text-shadow: none !important; | |
cursor: default; | |
} | |
.videoServerTypeSelectMenu ul { | |
margin: 2px 8px; | |
} | |
.videoServerTypeSelectMenu li { | |
padding: 3px 4px; | |
} | |
.videoServerTypeSelectMenu li.selected { | |
pointer-events: none; | |
text-shadow: 0 0 4px #99f, 0 0 8px #99f !important; | |
} | |
.videoServerTypeSelectMenu .smileVideoQuality, | |
.videoServerTypeSelectMenu .dmcVideoQuality { | |
font-size: 80%; | |
padding-left: 28px; | |
} | |
.videoServerTypeSelectMenu .currentVideoQuality { | |
color: #ccf; | |
font-size: 80%; | |
text-align: center; | |
} | |
.videoServerTypeSelectMenu .dmcVideoQuality.selected span:before, | |
.videoServerTypeSelectMenu .smileVideoQuality.selected span:before { | |
left: 22px; | |
font-size: 80%; | |
} | |
.videoServerTypeSelectMenu .currentVideoQuality.selected span:before { | |
display: none; | |
} | |
/* dmcを使用不能の時はdmc選択とdmc画質選択を薄く */ | |
.zenzaPlayerContainer:not(.is-dmcAvailable) .serverType.select-server-dmc, | |
.zenzaPlayerContainer:not(.is-dmcAvailable) .dmcVideoQuality, | |
.zenzaPlayerContainer:not(.is-dmcAvailable) .currentVideoQuality { | |
opacity: 0.4; | |
pointer-events: none; | |
text-shadow: none !important; | |
} | |
.zenzaPlayerContainer:not(.is-dmcAvailable) .currentVideoQuality { | |
display: none; | |
} | |
.zenzaPlayerContainer:not(.is-dmcAvailable) .serverType.select-server-dmc span:before, | |
.zenzaPlayerContainer:not(.is-dmcAvailable) .dmcVideoQuality span:before{ | |
display: none !important; | |
} | |
.zenzaPlayerContainer:not(.is-dmcAvailable) .serverType { | |
pointer-events: none; | |
} | |
/* dmcを使用している時はsmileの画質選択を薄く */ | |
.is-dmc-playing .smileVideoQuality { | |
display: none; | |
} | |
/* dmcを選択していない状態ではdmcの画質選択を隠す */ | |
.is-smile-playing .currentVideoQuality, | |
.is-smile-playing .dmcVideoQuality { | |
display: none; | |
} | |
@media screen and (max-width: 768px) { | |
.controlItemContainer.center { | |
left: 0%; | |
transform: translate(0, 0); | |
} | |
} | |
.ZenzaWatchVer { | |
display: none; | |
} | |
.ZenzaWatchVer[data-env="DEV"] { | |
display: inline-block; | |
color: #999; | |
position: absolute; | |
right: 0; | |
background: transparent !important; | |
transform: translate(100%, 0); | |
font-size: 12px; | |
line-height: 32px; | |
pointer-events: none; | |
} | |
.progressWave { | |
display: none; | |
} | |
.is-stalled .progressWave, | |
.is-loading .progressWave { | |
display: inline-block; | |
position: absolute; | |
left: 0; | |
top: 1px; | |
z-index: 400; | |
width: 40%; | |
height: calc(100% - 2px); | |
background: linear-gradient( | |
to right, | |
rgba(0,0,0,0), | |
${util.toRgba('#ffffcc', 0.3)}, | |
rgba(0,0,0) | |
); | |
mix-blend-mode: lighten; | |
animation-name: progressWave; | |
animation-iteration-count: infinite; | |
animation-duration: 4s; | |
animation-timing-function: linear; | |
animation-delay: -1s; | |
} | |
@keyframes progressWave { | |
0% { transform: translate3d(-100%, 0, 0) translate3d(-5vw, 0, 0); } | |
100% { transform: translate3d(100%, 0, 0) translate3d(150vw, 0, 0); } | |
} | |
.is-seeking .progressWave { | |
display: none; | |
} | |
`, {className: 'videoControlBar'}); | |
util.addStyle(` | |
.videoControlBar { | |
width: 100% !important; /* 100vwだと縦スクロールバーと被る */ | |
} | |
`, {className: 'screenMode for-popup videoControlBar', disabled: true}); | |
util.addStyle(` | |
body .videoControlBar { | |
position: absolute !important; /* firefoxのバグ対策 */ | |
opacity: 0; | |
background: none; | |
} | |
.volumeChanging .videoControlBar, | |
.is-mouseMoving .videoControlBar { | |
opacity: 0.7; | |
background: rgba(0, 0, 0, 0.5); | |
} | |
.showVideoControlBar .videoControlBar { | |
opacity: 1 !important; | |
background: #000 !important; | |
} | |
.videoControlBar.is-dragging, | |
.videoControlBar:hover { | |
opacity: 1; | |
background: rgba(0, 0, 0, 0.9); | |
} | |
.fullscreenControlBarModeMenu { | |
display: inline-block; | |
} | |
.fullscreenControlBarModeSelectMenu { | |
padding: 2px 4px; | |
font-size: 12px; | |
line-height: 15px; | |
font-size: 16px !important; | |
text-shadow: none !important; | |
} | |
.fullscreenControlBarModeSelectMenu ul { | |
margin: 2px 8px; | |
} | |
.fullscreenControlBarModeSelectMenu li { | |
padding: 3px 4px; | |
} | |
.videoServerTypeSelectMenu li.selected { | |
pointer-events: none; | |
text-shadow: 0 0 4px #99f, 0 0 8px #99f !important; | |
} | |
.fullscreenControlBarModeMenu li:focus-within, | |
body[data-fullscreen-control-bar-mode="auto"] .fullscreenControlBarModeMenu [data-param="auto"], | |
body[data-fullscreen-control-bar-mode="always-show"] .fullscreenControlBarModeMenu [data-param="always-show"], | |
body[data-fullscreen-control-bar-mode="always-hide"] .fullscreenControlBarModeMenu [data-param="always-hide"] { | |
color: #ff9; | |
outline: none; | |
} | |
`, {className: 'screenMode for-full videoControlBar', disabled: true}); | |
util.addStyle(` | |
.screenModeSelectMenu { | |
display: none; | |
} | |
.controlItemContainer.left { | |
top: auto; | |
transform-origin: top left; | |
} | |
.seekBarContainer { | |
top: auto; | |
bottom: 0; | |
z-index: 300; | |
} | |
.seekBarContainer:hover .seekBarShadow { | |
height: 14px; | |
top: -12px; | |
} | |
.seekBar { | |
margin-top: 0px; | |
margin-bottom: -14px; | |
height: 24px; | |
transition: none; | |
} | |
.screenModeMenu { | |
display: none; | |
} | |
.controlItemContainer.center { | |
top: auto; | |
} | |
.zenzaStoryboardOpen .controlItemContainer.center { | |
background: transparent; | |
} | |
.zenzaStoryboardOpen .controlItemContainer.center .scalingUI { | |
background: rgba(32, 32, 32, 0.5); | |
} | |
.zenzaStoryboardOpen .controlItemContainer.center .scalingUI:hover { | |
background: rgba(32, 32, 32, 0.8); | |
} | |
.controlItemContainer.right { | |
top: auto; | |
} | |
`, {className: 'screenMode for-screen-full videoControlBar', disabled: true}); | |
VideoControlBar.__tpl__ = (` | |
<div class="videoControlBar" data-command="nop"> | |
<div class="seekBarContainer"> | |
<div class="seekBarShadow"></div> | |
<div class="seekBar"> | |
<div class="seekBarPointer"></div> | |
<div class="bufferRange"></div> | |
<div class="progressWave"></div> | |
<input type="range" class="seekRange" min="0" step="any"> | |
<canvas width="200" height="10" class="heatMap zenzaHeatMap"></canvas> | |
</div> | |
<zenza-seekbar-label class="resumePointer" data-command="seekTo" data-text="ここまで見た"></zenza-seekbar-label> | |
<zenza-seekbar-label class="resumePointer" data-command="seekTo" data-text="ここまで見た"></zenza-seekbar-label> | |
<zenza-seekbar-label class="resumePointer" data-command="seekTo" data-text="ここまで見た"></zenza-seekbar-label> | |
<zenza-seekbar-label class="resumePointer" data-command="seekTo" data-text="ここまで見た"></zenza-seekbar-label> | |
<zenza-seekbar-label class="resumePointer" data-command="seekTo" data-text="ここまで見た"></zenza-seekbar-label> | |
</div> | |
<div class="controlItemContainer left"> | |
<div class="scalingUI"> | |
<div class="ZenzaWatchVer" data-env="${ZenzaWatch.env}">ver ${ZenzaWatch.version}${ZenzaWatch.env === 'DEV' ? '(Dev)' : ''}</div> | |
</div> | |
</div> | |
<div class="controlItemContainer center"> | |
<div class="scalingUI"> | |
<div class="seekBarContainer-mainControl"> | |
<div class="prevVideo controlButton playControl" data-command="playPreviousVideo" data-param="0"> | |
<div class="controlButtonInner">➠</div> | |
<div class="tooltip">前の動画</div> | |
</div> | |
<div class="toggleStoryboard controlButton playControl forPremium" data-command="toggleStoryboard"> | |
<div class="controlButtonInner"></div> | |
<div class="tooltip">シーンサーチ</div> | |
</div> | |
<div class="loopSwitch controlButton playControl" data-command="toggle-loop"> | |
<div class="controlButtonInner">↻</div> | |
<div class="tooltip">リピート</div> | |
</div> | |
<div class="seekTop controlButton playControl" data-command="seek" data-param="0"> | |
<div class="controlButtonInner">⇤</div> | |
<div class="tooltip">先頭</div> | |
</div> | |
<div class="togglePlay controlButton playControl" data-command="togglePlay"> | |
<span class="pause"></span> | |
<span class="play">▶</span> | |
</div> | |
<div class="playbackRateMenu controlButton" tabindex="-1" data-has-submenu="1"> | |
<div class="controlButtonInner"></div> | |
<div class="tooltip">再生速度</div> | |
<div class="playbackRateSelectMenu zenzaPopupMenu zenzaSubMenu"> | |
<div class="triangle"></div> | |
<p class="caption">再生速度</p> | |
<ul> | |
<li class="playbackRate" data-command="playbackRate" data-param="10"><span>10倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="5" ><span>5倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="4" ><span>4倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="3" ><span>3倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="2" ><span>2倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="1.75"><span>1.75倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="1.5"><span>1.5倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="1.25"><span>1.25倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="1.0"><span>標準速度(x1)</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="0.75"><span>0.75倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="0.5"><span>0.5倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="0.25"><span>0.25倍</span></li> | |
<li class="playbackRate" data-command="playbackRate" data-param="0.1"><span>0.1倍</span></li> | |
</ul> | |
</div> | |
</div> | |
<div class="videoTime"> | |
<span class="currentTimeLabel"></span>/<span class="durationLabel"></span> | |
</div> | |
<div class="muteSwitch controlButton" data-command="toggle-mute"> | |
<div class="tooltip">ミュート(M)</div> | |
<div class="menuButtonInner mute-off">🔊</div> | |
<div class="menuButtonInner mute-on">🔇</div> | |
</div> | |
<div class="volumeControl"> | |
<zenza-range-bar><input class="volumeRange" type="range" value="0.5" min="0.01" max="1" step="any"></zenza-range-bar> | |
</div> | |
<div class="nextVideo controlButton playControl" data-command="playNextVideo" data-param="0"> | |
<div class="controlButtonInner">➠</div> | |
<div class="tooltip">次の動画</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="controlItemContainer right"> | |
<div class="scalingUI"> | |
<div class="videoServerTypeMenu controlButton forYouTube" data-command="reload" title="ZenTube解除"> | |
<div class="controlButtonInner">画</div> | |
</div> | |
<div class="videoServerTypeMenu controlButton" tabindex="-1" data-has-submenu="1"> | |
<div class="controlButtonInner">画</div> | |
<div class="tooltip">動画サーバー・画質</div> | |
<div class="videoServerTypeSelectMenu zenzaPopupMenu zenzaSubMenu"> | |
<div class="triangle"></div> | |
<p class="caption">動画サーバー・画質</p> | |
<ul> | |
<li class="serverType select-server-dmc" data-command="update-videoServerType" data-param="dmc"> | |
<span>新システムを使用</span> | |
<p class="currentVideoQuality"></p> | |
</li> | |
<li class="dmcVideoQuality selected select-dmc-auto" data-command="update-dmcVideoQuality" data-param="auto"><span>自動(auto)</span></li> | |
<li class="dmcVideoQuality selected select-dmc-veryhigh" data-command="update-dmcVideoQuality" data-param="veryhigh"><span>超(1080) 優先</span></li> | |
<li class="dmcVideoQuality selected select-dmc-high" data-command="update-dmcVideoQuality" data-param="high"><span>高(720) 優先</span></li> | |
<li class="dmcVideoQuality selected select-dmc-mid" data-command="update-dmcVideoQuality" data-param="mid"><span>中(480-540)</span></li> | |
<li class="dmcVideoQuality selected select-dmc-low" data-command="update-dmcVideoQuality" data-param="low"><span>低(360)</span></li> | |
<li class="serverType select-server-smile" data-command="update-videoServerType" data-param="smile"> | |
<span>旧システムを使用</span> | |
</li> | |
<li class="smileVideoQuality select-smile-default" data-command="update-forceEconomy" data-param="false" data-type="bool"><span>自動</span></li> | |
<li class="smileVideoQuality select-smile-economy" data-command="update-forceEconomy" data-param="true" data-type="bool"><span>エコノミー固定</span></li> | |
</ul> | |
</div> | |
</div> | |
<div class="screenModeMenu controlButton" tabindex="-1" data-has-submenu="1"> | |
<div class="tooltip">画面サイズ・モード変更</div> | |
<div class="controlButtonInner">⎚</div> | |
<div class="screenModeSelectMenu zenzaPopupMenu zenzaSubMenu"> | |
<div class="triangle"></div> | |
<p class="caption">画面モード</p> | |
<ul> | |
<li class="screenMode mode3D" data-command="screenMode" data-param="3D"><span>3D</span></li> | |
<li class="screenMode small" data-command="screenMode" data-param="small"><span>小</span></li> | |
<li class="screenMode sideView" data-command="screenMode" data-param="sideView"><span>横</span></li> | |
<li class="screenMode normal" data-command="screenMode" data-param="normal"><span>中</span></li> | |
<li class="screenMode wide" data-command="screenMode" data-param="wide"><span>WIDE</span></li> | |
<li class="screenMode big" data-command="screenMode" data-param="big"><span>大</span></li> | |
</ul> | |
</div> | |
</div> | |
<div class="fullscreenControlBarModeMenu controlButton" tabindex="-1" data-has-submenu="1"> | |
<div class="tooltip">ツールバーの表示</div> | |
<div class="controlButtonInner">📌</div> | |
<div class="fullscreenControlBarModeSelectMenu zenzaPopupMenu zenzaSubMenu"> | |
<div class="triangle"></div> | |
<p class="caption">ツールバーの表示</p> | |
<ul> | |
<li tabindex="-1" data-command="update-fullscreenControlBarMode" data-param="always-show"><span>常に固定</span></li> | |
<li tabindex="-1" data-command="update-fullscreenControlBarMode" data-param="always-hide"><span>常に隠す</span></li> | |
<li tabindex="-1" data-command="update-fullscreenControlBarMode" data-param="auto"><span>画面サイズ自動</span></li> | |
</ul> | |
</div> | |
</div> | |
<div class="fullscreenSwitch controlButton" data-command="fullscreen"> | |
<div class="tooltip">フルスクリーン(F)</div> | |
<div class="controlButtonInner"> | |
<!-- TODO: YouTubeと同じにする --> | |
<span class="toFull">⇲</span> | |
<span class="returnFull">⇱</span> | |
</div> | |
</div> | |
<div class="settingPanelSwitch controlButton" data-command="settingPanel"> | |
<div class="controlButtonInner">⚙</div> | |
<div class="tooltip">設定</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
`).trim(); | |
function HeatMapInitFunc(self) { | |
class HeatMapModel { | |
constructor(params) { | |
this.resolution = params.resolution || HeatMapModel.RESOLUTION; | |
this.reset(); | |
} | |
reset() { | |
this._duration = -1; | |
this._chatReady = false; | |
this.map = []; | |
} | |
set duration(duration) { | |
if (this._duration === duration) { return; } | |
this._duration = duration; | |
this.update(); | |
} | |
get duration() { | |
return this._duration; | |
} | |
set chatList(comment) { | |
this._chat = comment; | |
this._chatReady = true; | |
this.update(); | |
} | |
update() { | |
if (this._duration < 0 || !this._chatReady) { | |
return false; | |
} | |
const map = this.map = this.getHeatMap(); | |
return !!map.length; | |
} | |
getHeatMap() { | |
const chatList = | |
this._chat.top.concat(this._chat.naka, this._chat.bottom) | |
.filter(chat => chat.fork !== 2); // かんたんコメント除外 | |
const duration = this._duration; | |
if (duration < 1) { return []; } | |
const map = new Array(Math.max(Math.min(this.resolution, Math.floor(duration)), 1)); | |
const length = map.length; | |
let i = length; | |
while(i > 0) map[--i] = 0; | |
const ratio = duration > map.length ? (map.length / duration) : 1; | |
for (i = chatList.length - 1; i >= 0; i--) { | |
let nicoChat = chatList[i]; | |
let pos = nicoChat.vpos; | |
let mpos = Math.min(Math.floor(pos * ratio / 100), map.length -1); | |
map[mpos]++; | |
} | |
for (i = 0; i < Math.min(length, 20); i++) {// 先頭付近は「うぽつ」などで一極集中しがちなのでリミットを設ける | |
map[i] = Math.min(5, map[i]); | |
} | |
for (; i < Math.min(length, 60); i++) { | |
map[i] = Math.min(10, map[i]); | |
} | |
map.length = length; | |
return map; | |
} | |
} | |
HeatMapModel.RESOLUTION = 200; | |
class HeatMapView { | |
constructor(params) { | |
this.model = params.model; | |
this.container = params.container; | |
this.canvas = params.canvas; | |
} | |
initializePalette() { | |
this._palette = []; | |
for (let c = 0; c < 256; c++) { | |
const | |
r = Math.floor((c > 127) ? (c / 2 + 128) : 0), | |
g = Math.floor((c > 127) ? (255 - (c - 128) * 2) : (c * 2)), | |
b = Math.floor((c > 127) ? 0 : (255 - c * 2)); | |
this._palette.push(`rgb(${r}, ${g}, ${b})`); | |
} | |
} | |
initializeCanvas() { | |
if (!this.canvas) { | |
this.canvas = this.container.querySelector('canvas.heatMap'); | |
} | |
this.context = this.canvas.getContext('2d', {alpha: false, desynchronized: true}); | |
this.width = this.canvas.width; | |
this.height = this.canvas.height; | |
this.reset(); | |
} | |
reset() { | |
if (!this.context) { return; } | |
this.context.fillStyle = this._palette[0]; | |
this.context.beginPath(); | |
this.context.fillRect(0, 0, this.width, this.height); | |
} | |
async toDataURL() { | |
if (!this.canvas) { | |
return ''; | |
} | |
const type = 'image/png'; | |
const canvas = this.canvas; | |
try { | |
return canvas.toDataURL(type); | |
} catch(e) { | |
const blob = await new Promise(res => { | |
if (canvas.convertToBlob) { | |
return res(canvas.convertToBlob({type})); | |
} | |
this.canvas.toBlob(res, type); | |
}).catch(e => null); | |
if (!blob) { | |
return ''; | |
} | |
return new Promise((ok, ng) => { | |
const reader = new FileReader(); | |
reader.onload = () => { ok(reader.result); }; | |
reader.onerror = e => ng(e); | |
reader.readAsDataURL(blob); | |
}).catch(e => ''); | |
} | |
} | |
update(map) { | |
if (!this._isInitialized) { | |
this._isInitialized = true; | |
this.initializePalette(); | |
this.initializeCanvas(); | |
this.reset(); | |
} | |
map = map || this.model.map; | |
if (!map.length) { return false; } | |
console.time('draw HeatMap'); | |
let max = 0, i; | |
for (i = Math.max(map.length - 4, 0); i >= 0; i--) max = Math.max(map[i], max); | |
if (max > 0) { | |
let rate = 255 / max; | |
for (i = map.length - 1; i >= 0; i--) { | |
map[i] = Math.min(255, Math.floor(map[i] * rate)); | |
} | |
} else { | |
console.timeEnd('draw HeatMap'); | |
return false; | |
} | |
const | |
scale = map.length >= this.width ? 1 : (this.width / Math.max(map.length, 1)), | |
blockWidth = (this.width / map.length) * scale, | |
context = this.context; | |
for (i = map.length - 1; i >= 0; i--) { | |
context.fillStyle = this._palette[parseInt(map[i], 10)] || this._palette[0]; | |
context.beginPath(); | |
context.fillRect(i * scale, 0, blockWidth, this.height); | |
} | |
console.timeEnd('draw HeatMap'); | |
context.commit && context.commit(); | |
return true; | |
} | |
} | |
class HeatMap { | |
constructor(params) { | |
this.model = new HeatMapModel({}); | |
this.view = new HeatMapView({ | |
model: this.model, | |
container: params.container, | |
canvas: params.canvas | |
}); | |
this.reset(); | |
} | |
reset() { | |
this.model.reset(); | |
this.view.reset(); | |
} | |
set duration(duration) { | |
if (this.model.duration === duration) { return; } | |
this.model.duration = duration; | |
this.view.update() && this.toDataURL().then(dataURL => { | |
self.emit('heatMapUpdate', {map: this.map, duration: this.duration, dataURL}); | |
}); | |
} | |
get duration() { | |
return this.model.duration; | |
} | |
set chatList(chatList) { | |
this.model.chatList = chatList; | |
this.view.update() && this.toDataURL().then(dataURL => { | |
self.emit('heatMapUpdate', {map: this.map, duration: this.duration, dataURL}); | |
}); | |
} | |
get canvas() { | |
return this.view.canvas || {}; | |
} | |
get map() { | |
return this.model.map; | |
} | |
async toDataURL() { | |
return this.view.toDataURL(); | |
} | |
} | |
return HeatMap; | |
} // end of HeatMapInitFunc | |
const HeatMapWorker = (() => { | |
const _func = function(self) { | |
const HeatMap = HeatMapInitFunc(self); | |
let heatMap; | |
const init = ({canvas}) => heatMap = new HeatMap({canvas}); | |
const update = ({chatList}) => heatMap.chatList = chatList; | |
const duration = ({duration}) => heatMap.duration = duration; | |
const reset = () => heatMap.reset(); | |
self.onmessage = async ({command, params}) => { | |
let result = {status: 'ok'}; | |
switch (command) { | |
case 'init': | |
init(params); | |
break; | |
case 'update': | |
update(params); | |
break; | |
case 'duration': | |
duration(params); | |
break; | |
case 'reset': | |
reset(params); | |
break; | |
case 'getData': | |
result.dataURL = await heatMap.toDataURL(); | |
result.map = heatMap.map; | |
result.duration = heatMap.duration; | |
break; | |
} | |
return result; | |
}; | |
}; | |
const func = ` | |
function(self) { | |
${HeatMapInitFunc.toString()}; | |
(${_func.toString()})(self); | |
} | |
`; | |
const isOffscreenCanvasAvailable = !!HTMLCanvasElement.prototype.transferControlToOffscreen; | |
let worker; | |
const init = async ({container, width, height}) => { | |
if (!isOffscreenCanvasAvailable) { | |
const HeatMap = HeatMapInitFunc({ | |
emit: (...args) => global.emitter.emit(...args) | |
}); | |
return new HeatMap({container, width, height}); | |
} | |
worker = worker || workerUtil.createCrossMessageWorker(func, {name: 'HeatMapWorker'}); | |
const canvas = container.querySelector('canvas.heatMap'); | |
const layer = canvas.transferControlToOffscreen(); | |
await worker.post({command: 'init', params: {canvas: layer}}, {transfer: [layer]}); | |
let _chatList, _duration; | |
return { | |
canvas, | |
update(chatList) { | |
chatList = { | |
top: chatList.top.map(c => { return {...c.props, ...{group: null}}; }), | |
naka: chatList.naka.map(c => { return {...c.props, ...{group: null}}; }), | |
bottom: chatList.bottom.map(c => { return {...c.props, ...{group: null}}; }) | |
}; | |
return worker.post({command: 'update', params: {chatList}}); | |
}, | |
get duration() { return _duration; }, | |
set duration(d) { | |
_duration = d; | |
worker.post({command: 'duration', params: {duration: d}}); }, | |
reset: () => worker.post({command: 'reset', params: {}}), | |
get chatList() {return _chatList;}, | |
set chatList(chatList) { this.update(_chatList = chatList); } | |
}; | |
}; | |
return {init}; | |
})(); | |
const HeatMap = HeatMapInitFunc({ | |
emit: (...args) => global.emitter.emit(...args) | |
}); | |
class CommentPreviewModel extends Emitter { | |
reset() { | |
this._chatReady = false; | |
this._vpos = -1; | |
this.emit('reset'); | |
} | |
set chatList(chatList) { | |
const list = chatList | |
.top | |
.concat(chatList.naka, chatList.bottom) | |
.sort((a, b) => a.vpos - b.vpos); | |
this._chatList = list; | |
this._chatReady = true; | |
this.update(); | |
} | |
get chatList() { | |
return this._chatList || []; | |
} | |
set currentTime(sec) { | |
this.vpos = sec * 100; | |
} | |
set vpos(vpos) { | |
if (this._vpos !== vpos) { | |
this._vpos = vpos; | |
this.emit('vpos', vpos); | |
} | |
} | |
get currentIndex() { | |
if (this._vpos < 0 || !this._chatReady) { | |
return -1; | |
} | |
return this.getVposIndex(this._vpos); | |
} | |
getVposIndex(vpos) { | |
const list = this._chatList; | |
if (!list) { return -1; } | |
for (let i = list.length - 1; i >= 0; i--) { | |
const chat = list[i], cv = chat.vpos; | |
if (cv <= vpos - 400) { | |
return i + 1; | |
} | |
} | |
return -1; | |
} | |
get currentChatList() { | |
if (this._vpos < 0 || !this._chatReady) { | |
return []; | |
} | |
return this.getItemByVpos(this._vpos); | |
} | |
getItemByVpos(vpos) { | |
const list = this._chatList; | |
const result = []; | |
for (let i = 0, len = list.length; i < len; i++) { | |
const chat = list[i], cv = chat.vpos, diff = vpos - cv; | |
if (diff >= -100 && diff <= 400) { | |
result.push(chat); | |
} | |
} | |
return result; | |
} | |
getItemByUniqNo(uniqNo) { | |
return this._chatList.find(chat => chat.uniqNo === uniqNo); | |
} | |
update() { | |
this.emit('update'); | |
} | |
} | |
class CommentPreviewView { | |
constructor(params) { | |
const model = this._model = params.model; | |
this._$parent = params.$container; | |
this._inviewTable = new Map; | |
this._chatList = []; | |
this._initializeDom(this._$parent); | |
model.on('reset', this._onReset.bind(this)); | |
model.on('update', _.debounce(this._onUpdate.bind(this), 10)); | |
model.on('vpos', this._onVpos.bind(this)); | |
this._mode = 'hover'; | |
this._left = 0; | |
this.update = _.throttle(this.update.bind(this), 200); | |
this.applyView = throttle.raf(this.applyView.bind(this)); | |
} | |
_initializeDom($parent) { | |
cssUtil.registerProps( | |
{name: '--buffer-range-left', syntax: '<percentage>', initialValue: '0%',inherits: false}, | |
{name: '--buffer-range-scale', syntax: '<number>', initialValue: 0, inherits: false}, | |
); | |
const $view = util.$.html(CommentPreviewView.__tpl__); | |
const view = this._view = $view[0]; | |
this._list = view.querySelector('.listContainer'); | |
$view.on('click', this._onClick.bind(this)) | |
.on('wheel', e => e.stopPropagation(), {passive: true}) | |
.on('scroll', | |
_.throttle(this._onScroll.bind(this), 50, {trailing: false}), {passive: true}); | |
$parent.append($view); | |
} | |
set mode(v) { | |
if (v === 'list') { | |
util.StyleSwitcher.update({ | |
on: '.commentPreview.list', off: '.commentPreview.hover'}); | |
} else { | |
util.StyleSwitcher.update({ | |
on: '.commentPreview.hover', off: '.commentPreview.list'}); | |
} | |
this._mode = v; | |
} | |
_onClick(e) { | |
e.stopPropagation(); | |
const target = e.target.closest('[data-command]'); | |
const view = this._view; | |
const command = target ? target.dataset.command : ''; | |
const nicoChatElement = e.target.closest('.nicoChat'); | |
const uniqNo = parseInt(nicoChatElement.dataset.nicochatUniqNo, 10); | |
const nicoChat = this._model.getItemByUniqNo(uniqNo); | |
if (command && nicoChat) { | |
view.classList.add('is-updating'); | |
window.setTimeout(() => view.classList.remove('is-updating'), 3000); | |
switch (command) { | |
case 'addUserIdFilter': | |
util.dispatchCommand(e.target, command, nicoChat.userId); | |
break; | |
case 'addWordFilter': | |
util.dispatchCommand(e.target, command, nicoChat.text); | |
break; | |
case 'addCommandFilter': | |
util.dispatchCommand(e.target, command, nicoChat.cmd); | |
break; | |
} | |
return; | |
} | |
const vpos = nicoChatElement.dataset.vpos; | |
if (vpos !== undefined) { | |
util.dispatchCommand(e.target, 'seek', vpos / 100); | |
} | |
} | |
_onUpdate() { | |
this.updateList(); | |
} | |
_onVpos(vpos) { | |
const itemHeight = CommentPreviewView.ITEM_HEIGHT; | |
const index = this._currentStartIndex = Math.max(0, this._model.currentIndex); | |
this._currentEndIndex = Math.max(0, this._model.getVposIndex(vpos + 400)); | |
this._scrollTop = itemHeight * index; | |
this._currentTime = vpos / 100; | |
this._refreshInviewElements(this._scrollTop); | |
} | |
_onResize() { | |
this._refreshInviewElements(); | |
} | |
_onScroll() { | |
this._scrollTop = -1; | |
this._refreshInviewElements(); | |
} | |
_onReset() { | |
this._list.textContent = ''; | |
this._inviewTable.clear(); | |
this._scrollTop = 0; | |
this._newListElements = null; | |
this._chatList = []; | |
} | |
updateList() { | |
const chatList = this._chatList = this._model.chatList; | |
if (!chatList.length) { | |
this._isListUpdated = false; | |
return; | |
} | |
const itemHeight = CommentPreviewView.ITEM_HEIGHT; | |
this._list.style.height = `${(chatList.length + 2) * itemHeight}px`; | |
this._isListUpdated = false; | |
} | |
_refreshInviewElements(scrollTop) { | |
if (!this._view) { return; } | |
const itemHeight = CommentPreviewView.ITEM_HEIGHT; | |
scrollTop = _.isNumber(scrollTop) ? scrollTop : this._view.scrollTop; | |
const viewHeight = CommentPreviewView.MAX_HEIGHT; | |
const viewBottom = scrollTop + viewHeight; | |
const chatList = this._chatList; | |
if (!chatList || chatList.length < 1) { return; } | |
const startIndex = | |
this._mode === 'list' ? | |
Math.max(0, Math.floor(scrollTop / itemHeight) - 5) : | |
this._currentStartIndex; | |
const endIndex = | |
this._mode === 'list' ? | |
Math.min(chatList.length, Math.floor(viewBottom / itemHeight) + 5) : | |
Math.min(this._currentEndIndex, this._currentStartIndex + 15); | |
const newItems = [], inviewTable = this._inviewTable; | |
for (let i = startIndex; i < endIndex; i++) { | |
const chat = chatList[i]; | |
if (inviewTable.has(i) || !chat) { continue; } | |
const listItem = CommentPreviewChatItem.create(chat, i); | |
newItems.push(listItem); | |
inviewTable.set(i, listItem); | |
} | |
if (newItems.length < 1) { return; } | |
for (const i of inviewTable.keys()) { | |
if (i >= startIndex && i <= endIndex) { continue; } | |
inviewTable.get(i).remove(); | |
inviewTable.delete(i); | |
} | |
this._newListElements = this._newListElements || document.createDocumentFragment(); | |
this._newListElements.append(...newItems); | |
this.applyView(); | |
} | |
get isEmpty() { | |
return this._chatList.length < 1; | |
} | |
update(left) { | |
if (this._isListUpdated) { | |
this.updateList(); | |
} | |
if (this.isEmpty) { | |
return; | |
} | |
const width = this._mode === 'list' ? | |
CommentPreviewView.WIDTH : CommentPreviewView.HOVER_WIDTH; | |
const containerWidth = this._innerWidth = this._innerWidth || global.innerWidth; | |
left = Math.min(Math.max(0, left - CommentPreviewView.WIDTH / 2), containerWidth - width); | |
this._left = left; | |
this.applyView(); | |
} | |
applyView() { | |
const view = this._view; | |
cssUtil.setProps( | |
[view, '--current-time', cssUtil.s(this._currentTime)], | |
[view, '--scroll-top', cssUtil.px(this._scrollTop)], | |
[view, '--trans-x-pp', cssUtil.px(this._left)] | |
); | |
if (this._newListElements && this._newListElements.childElementCount) { | |
this._list.append(this._newListElements); | |
} | |
if (this._scrollTop > 0 && this._mode === 'list') { | |
this._view.scrollTop = this._scrollTop; | |
this._scrollTop = -1; | |
} | |
} | |
hide() { | |
} | |
} | |
class CommentPreviewChatItem { | |
static get html() { | |
return ` | |
<li class="nicoChat"> | |
<span class="vposTime"></span> | |
<span class="text"></span> | |
<span class="addFilter addUserIdFilter" | |
data-command="addUserIdFilter" title="NGユーザー">NGuser</span> | |
<span class="addFilter addWordFilter" | |
data-command="addWordFilter" title="NGワード">NGword</span> | |
</li> | |
`.trim(); | |
} | |
static get template() { | |
if (!this._template) { | |
const t = document.createElement('template'); | |
t.id = `${this.name}_${Date.now()}`; | |
t.innerHTML = this.html; | |
const content = t.content; | |
this._template = { | |
clone: () => document.importNode(t.content, true), | |
chat: content.querySelector('.nicoChat'), | |
time: content.querySelector('.vposTime'), | |
text: t.content.querySelector('.text') | |
}; | |
} | |
return this._template; | |
} | |
static create(chat, idx) { | |
const itemHeight = CommentPreviewView.ITEM_HEIGHT; | |
const text = chat.text; | |
const date = (new Date(chat.date * 1000)).toLocaleString(); | |
const vpos = chat.vpos; | |
const no = chat.no; | |
const uniqNo = chat.uniqNo; | |
const oe = idx % 2 === 0 ? 'even' : 'odd'; | |
const title = `${no} : 投稿日 ${date}\nID:${chat.userId}\n${text}\n`; | |
const color = chat.color || '#fff'; | |
const shadow = color === '#fff' ? '' : `text-shadow: 0 0 1px ${color};`; | |
const vposToTime = vpos => util.secToTime(Math.floor(vpos / 100)); | |
const t = this.template; | |
t.chat.className = `nicoChat fork${chat.fork} ${oe}`; | |
t.chat.id = `commentPreviewItem${idx}`; | |
t.chat.dataset.vpos = vpos; | |
t.chat.dataset.nicochatUniqNo = uniqNo; | |
t.time.textContent = `${vposToTime(vpos)}: `; | |
t.text.title = title; | |
t.text.style = shadow; | |
t.text.textContent = text; | |
t.chat.style.cssText = ` | |
top: ${idx * itemHeight}px; | |
--duration: ${chat.duration}s; | |
--vpos-time: ${chat.vpos / 100}s; | |
`; | |
return t.clone().firstElementChild; | |
} | |
} | |
CommentPreviewView.MAX_HEIGHT = 200; | |
CommentPreviewView.WIDTH = 350; | |
CommentPreviewView.HOVER_WIDTH = 180; | |
CommentPreviewView.ITEM_HEIGHT = 20; | |
CommentPreviewView.__tpl__ = (` | |
<div class="zenzaCommentPreview"> | |
<div class="listContainer"></div> | |
</div> | |
`).trim(); | |
util.addStyle(` | |
.zenzaCommentPreview { | |
display: none; | |
position: absolute; | |
bottom: 16px; | |
opacity: 0.8; | |
max-height: ${CommentPreviewView.MAX_HEIGHT}px; | |
width: ${CommentPreviewView.WIDTH}px; | |
box-sizing: border-box; | |
color: #ccc; | |
overflow: hidden; | |
transform: translate(var(--trans-x-pp), 0); | |
transition: --trans-x-pp 0.2s; | |
will-change: transform; | |
} | |
.zenzaCommentPreview * { | |
box-sizing: border-box; | |
} | |
.is-wheelSeeking .zenzaCommentPreview, | |
.seekBarContainer:hover .zenzaCommentPreview { | |
display: block; | |
} | |
`, {className: 'commentPreview'}); | |
util.addStyle(` | |
.zenzaCommentPreview { | |
border-bottom: 24px solid transparent; | |
background: rgba(0, 0, 0, 0.4); | |
z-index: 100; | |
overflow: auto; | |
} | |
.zenzaCommentPreview:hover { | |
background: black; | |
} | |
.zenzaCommentPreview.is-updating { | |
transition: opacity 0.2s ease; | |
opacity: 0.3; | |
cursor: wait; | |
} | |
.zenzaCommentPreview.is-updating * { | |
pointer-evnets: none; | |
} | |
.listContainer { | |
bottom: auto; | |
padding: 4px; | |
pointer-events: none; | |
} | |
.zenzaCommentPreview:hover .listContainer { | |
pointer-events: auto; | |
} | |
.listContainer .nicoChat { | |
position: absolute; | |
left: 0; | |
display: block; | |
width: 100%; | |
height: ${CommentPreviewView.ITEM_HEIGHT}px; | |
padding: 2px 4px; | |
cursor: pointer; | |
white-space: nowrap; | |
text-overflow: ellipsis; | |
overflow: hidden; | |
animation-duration: var(--duration); | |
animation-delay: calc(var(--vpos-time) - var(--current-time) - 1s); | |
animation-name: preview-text-inview; | |
animation-timing-function: linear; | |
animation-play-state: paused !important; | |
} | |
@keyframes preview-text-inview { | |
0% { | |
color: #ffc; | |
} | |
100% { | |
color: #ffc; | |
} | |
} | |
.listContainer:hover .nicoChat.odd { | |
background: #333; | |
} | |
.listContainer .nicoChat.fork1 .vposTime { | |
color: #6f6; | |
} | |
.listContainer .nicoChat.fork2 .vposTime { | |
color: #66f; | |
} | |
.listContainer .nicoChat .no, | |
.listContainer .nicoChat .date, | |
.listContainer .nicoChat .userId { | |
display: none; | |
} | |
.listContainer .nicoChat:hover .no, | |
.listContainer .nicoChat:hover .date, | |
.listContainer .nicoChat:hover .userId { | |
display: inline-block; | |
white-space: nowrap; | |
} | |
.listContainer .nicoChat .text { | |
color: inherit !important; | |
} | |
.listContainer .nicoChat:hover .text { | |
color: #fff !important; | |
} | |
.listContainer .nicoChat .text:hover { | |
text-decoration: underline; | |
} | |
.listContainer .nicoChat .addFilter { | |
display: none; | |
position: absolute; | |
font-size: 10px; | |
color: #fff; | |
background: #666; | |
cursor: pointer; | |
top: 0; | |
} | |
.listContainer .nicoChat:hover .addFilter { | |
display: inline-block; | |
border: 1px solid #ccc; | |
box-shadow: 2px 2px 2px #333; | |
} | |
.listContainer .nicoChat .addFilter.addUserIdFilter { | |
right: 8px; | |
width: 48px; | |
} | |
.listContainer .nicoChat .addFilter.addWordFilter { | |
right: 64px; | |
width: 48px; | |
} | |
.listContainer .nicoChat .addFilter:active { | |
transform: translateY(2px); | |
} | |
.zenzaScreenMode_sideView .zenzaCommentPreview, | |
.zenzaScreenMode_small .zenzaCommentPreview { | |
background: rgba(0, 0, 0, 0.9); | |
} | |
`, {className: 'commentPreview list'}); | |
util.addStyle(` | |
.zenzaCommentPreview { | |
bottom: 24px; | |
box-sizing: border-box; | |
height: 140px; | |
z-index: 160; | |
transition: none; | |
color: #fff; | |
opacity: 0.6; | |
overflow: hidden; | |
pointer-events: none; | |
user-select: none; | |
contain: layout style size paint; | |
filter: drop-shadow(0 0 1px #000); | |
} | |
.listContainer { | |
bottom: auto; | |
width: 100%; | |
height: 100% !important; | |
margin: auto; | |
border: none; | |
contain: layout style size paint; | |
} | |
.listContainer .nicoChat { | |
display: block; | |
top: auto !important; | |
font-size: 16px; | |
line-height: 18px; | |
height: 18px; | |
white-space: nowrap; | |
} | |
.listContainer .nicoChat:nth-child(n + 8) { | |
transform: translateY(-144px); | |
} | |
.listContainer .nicoChat:nth-child(n + 16) { | |
transform: translateY(-288px); | |
} | |
.listContainer .nicoChat .text { | |
display: inline-block; | |
text-shadow: 1px 1px 1px #fff; | |
transform: translateX(260px); | |
visibility: hidden; | |
will-change: transform; | |
animation-duration: var(--duration); | |
animation-delay: calc(var(--vpos-time) - var(--current-time) - 1s); | |
animation-play-state: paused !important; | |
animation-name: preview-text-moving; | |
animation-timing-function: linear; | |
animation-fill-mode: forwards; | |
} | |
.listContainer .nicoChat .vposTime, | |
.listContainer .nicoChat .addFilter { | |
display: none !important; | |
} | |
@keyframes preview-text-moving { | |
0% { | |
visibility: visible; | |
} | |
100% { | |
visibility: hidden; | |
transform: translateX(85px) translateX(-100%); | |
} | |
} | |
`, {className: 'commentPreview hover', disabled: true}); | |
class CommentPreview { | |
constructor(params) { | |
this._model = new CommentPreviewModel({}); | |
this._view = new CommentPreviewView({ | |
model: this._model, | |
$container: params.$container | |
}); | |
this.reset(); | |
} | |
reset() { | |
this._model.reset(); | |
this._view.hide(); | |
} | |
set chatList(chatList) { | |
this._model.chatList = chatList; | |
} | |
set currentTime(sec) { | |
this._model.currentTime = sec; | |
} | |
update(left) { | |
this._view.update(left); | |
} | |
hide() { | |
} | |
set mode(v) { | |
if (v === this._mode) { return; } | |
this._mode = v; | |
this._view.mode = v; | |
} | |
get mode() { | |
return this._mode; | |
} | |
} | |
class SeekBarToolTip { | |
constructor(params) { | |
this._$container = params.$container; | |
this._storyboard = params.storyboard; | |
this._initializeDom(params.$container); | |
this._boundOnRepeat = this._onRepeat.bind(this); | |
this._boundOnMouseUp = this._onMouseUp.bind(this); | |
} | |
_initializeDom($container) { | |
util.addStyle(SeekBarToolTip.__css__); | |
const $view = this._$view = util.$.html(SeekBarToolTip.__tpl__); | |
this._currentTime = $view.find('.currentTime')[0]; | |
this.currentTimeLabel = TextLabel.create({ | |
container: this._currentTime, | |
name: 'currentTimeLabel', | |
text: '00:00', | |
style: { | |
widthPx: 50, | |
heightPx: 16, | |
fontFamily: 'monospace', | |
fontWeight: '', | |
fontSizePx: 12, | |
color: '#ccc' | |
} | |
}); | |
$view | |
.on('mousedown',this._onMouseDown.bind(this)) | |
.on('click', e => { e.stopPropagation(); e.preventDefault(); }); | |
this._seekBarThumbnail = new SeekBarThumbnail({ | |
storyboard: this._storyboard, | |
container: $view.find('.seekBarThumbnailContainer')[0] | |
}); | |
$container.append($view); | |
} | |
_onMouseDown(e) { | |
e.stopPropagation(); | |
const target = e.target.closest('[data-command]'); | |
if (!target) { | |
return; | |
} | |
const {command, param, repeat} = target.dataset; | |
if (!command) { return; } | |
util.dispatchCommand(e.target, command, param); | |
if (repeat === 'on') { | |
this._beginRepeat(command, param); | |
} | |
} | |
_onMouseUp(e) { | |
e.preventDefault(); | |
this._endRepeat(); | |
} | |
_beginRepeat(command, param) { | |
this._repeatCommand = command; | |
this._repeatParam = param; | |
util.$('body') | |
.on('mouseup.zenzaSeekbarToolTip', this._boundOnMouseUp); | |
this._$view | |
.on('mouseleave', this._boundOnMouseUp) | |
.on('mouseup', this._boundOnMouseUp); | |
if (this._repeatTimer) { | |
window.clearInterval(this._repeatTimer); | |
} | |
this._repeatTimer = window.setInterval(this._boundOnRepeat, 200); | |
this._isRepeating = true; | |
} | |
_endRepeat() { | |
this._isRepeating = false; | |
if (this._repeatTimer) { | |
window.clearInterval(this._repeatTimer); | |
this._repeatTimer = null; | |
} | |
util.$('body').off('mouseup.zenzaSeekbarToolTip'); | |
this._$view.off('mouseleave').off('mouseup'); | |
} | |
_onRepeat() { | |
if (!this._isRepeating) { | |
this._endRepeat(); | |
return; | |
} | |
util.dispatchCommand(this._$view[0], this._repeatCommand, this._repeatParam); | |
} | |
update(sec, left) { | |
const timeText = util.secToTime(sec); | |
if (this._timeText === timeText) { return; } | |
this._timeText = timeText; | |
this.currentTimeLabel && (this.currentTimeLabel.text = timeText); | |
const w = this.offsetWidth = this.offsetWidth || this._$view[0].offsetWidth; | |
const vw = this._innerWidth = this._innerWidth || window.innerWidth; | |
left = Math.max(0, Math.min(left - w / 2, vw - w)); | |
cssUtil.setProps([this._$view[0], '--trans-x-pp', cssUtil.px(left)]); | |
this._seekBarThumbnail.currentTime = sec; | |
} | |
} | |
SeekBarToolTip.__css__ = (` | |
.seekBarToolTip { | |
position: absolute; | |
display: inline-block; | |
visibility: hidden; | |
z-index: 300; | |
position: absolute; | |
box-sizing: border-box; | |
bottom: 24px; | |
left: 0; | |
width: 180px; | |
white-space: nowrap; | |
font-size: 10px; | |
background: rgba(0, 0, 0, 0.3); | |
z-index: 150; | |
opacity: 0; | |
border: 1px solid #666; | |
border-radius: 8px; | |
padding: 8px 4px 0; | |
will-change: transform; | |
transform: translate(var(--trans-x-pp), 0); | |
pointer-events: none; | |
} | |
.is-wheelSeeking .seekBarToolTip, | |
.is-dragging .seekBarToolTip, | |
.seekBarContainer:hover .seekBarToolTip { | |
opacity: 1; | |
visibility: visible; | |
} | |
.seekBarToolTipInner { | |
padding-bottom: 10px; | |
pointer-events: auto; | |
display: flex; | |
text-align: center; | |
vertical-aligm: middle; | |
width: 100%; | |
} | |
.is-wheelSeeking .seekBarToolTipInner, | |
.is-dragging .seekBarToolTipInner { | |
pointer-events: none; | |
} | |
.seekBarToolTipInner>* { | |
flex: 1; | |
} | |
.seekBarToolTip .currentTime { | |
display: inline-block; | |
height: 16px; | |
margin: 4px 0; | |
} | |
.seekBarToolTip .controlButton { | |
display: inline-block; | |
width: 40px; | |
height: 28px; | |
line-height: 22px; | |
font-size: 20px; | |
border-radius: 50%; | |
margin: 0; | |
cursor: pointer; | |
} | |
.seekBarToolTip .controlButton * { | |
cursor: pointer; | |
} | |
.seekBarToolTip .controlButton:hover { | |
text-shadow: 0 0 8px #fe9; | |
box-shdow: 0 0 8px #fe9; | |
} | |
.seekBarToolTip .controlButton:active { | |
font-size: 16px; | |
} | |
.seekBarToolTip .controlButton.toggleCommentPreview { | |
opacity: 0.5; | |
} | |
.enableCommentPreview .seekBarToolTip .controlButton.toggleCommentPreview { | |
opacity: 1; | |
background: rgba(0,0,0,0.01); | |
} | |
.is-fullscreen .seekBarToolTip { | |
bottom: 10px; | |
} | |
`).trim(); | |
SeekBarToolTip.__tpl__ = (` | |
<div class="seekBarToolTip"> | |
<div class="seekBarThumbnailContainer"></div> | |
<div class="seekBarToolTipInner"> | |
<div class="seekBarToolTipButtonContainer"> | |
<div class="controlButton backwardSeek" data-command="seekBy" data-param="-5" title="5秒戻る" data-repeat="on"> | |
<div class="controlButtonInner">⇦</div> | |
</div> | |
<div class="currentTime"></div> | |
<div class="controlButton toggleCommentPreview" data-command="toggleConfig" data-param="enableCommentPreview" title="コメントのプレビュー表示"> | |
<div class="menuButtonInner">💬</div> | |
</div> | |
<div class="controlButton forwardSeek" data-command="seekBy" data-param="5" title="5秒進む" data-repeat="on"> | |
<div class="controlButtonInner">⇨</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
`).trim(); | |
class SmoothSeekBarPointer { | |
constructor(params) { | |
this._pointer = params.pointer; | |
this._currentTime = 0; | |
this._duration = 1; | |
this._playbackRate = 1; | |
this._isSmoothMode = false; | |
this._isPausing = true; | |
this._isSeeking = false; | |
this._isStalled = false; | |
this.refresh = throttle.raf(this.refresh.bind(this)); | |
this.transformLeft = 0; | |
this.applyTransform = throttle.raf(() => { | |
const per = Math.min(100, this._timeToPer(this._currentTime)); | |
this._pointer.style.transform = `translateX(${global.innerWidth * per / 100 - 6}px)`; | |
}); | |
this._pointer.classList.toggle('is-notSmooth', !this._isSmoothMode); | |
params.playerState.onkey('isPausing', v => this.isPausing = v); | |
params.playerState.onkey('isSeeking', v => this.isSeeking = v); | |
params.playerState.onkey('isStalled', v => this.isStalled = v); | |
if (this._isSmoothMode) { | |
WindowResizeObserver.subscribe(() => this.refresh()); | |
} | |
} | |
get currentTime() { | |
return this._currentTime; | |
} | |
set currentTime(v) { | |
if (!this._isSmoothMode) { | |
this._currentTime = v; | |
return this.applyTransform(); | |
} | |
if (document.hidden) { return; } | |
if (this._currentTime === v) { | |
if (this.isPlaying) { | |
this._animation.currentTime = v; | |
this.isStalled = true; | |
return; | |
} | |
} else { | |
if (this.isStalled) { | |
this.isStalled = false; | |
} | |
} | |
this._currentTime = v; | |
if (this._animation && | |
Math.abs(v * 1000 - this._animation.currentTime) > 300) { | |
this._animation.currentTime = v * 1000; | |
} | |
} | |
_timeToPer(time) { | |
return (time / Math.max(this._duration, 1)) * 100; | |
} | |
set duration(v) { | |
if (this._duration === v) { return; } | |
this._duration = v; | |
this.refresh(); | |
} | |
set playbackRate(v) { | |
if (this._playbackRate === v) { return; } | |
this._playbackRate = v; | |
if (!this._animation) { return; } | |
this._animation.playbackRate = v; | |
} | |
get isPausing() { | |
return this._isPausing; | |
} | |
set isPausing(v) { | |
if (this._isPausing === v) { return; } | |
this._isPausing = v; | |
this._updatePlaying(); | |
} | |
get isSeeking() { | |
return this._isSeeking; | |
} | |
set isSeeking(v) { | |
if (this._isSeeking === v) { return; } | |
this._isSeeking = v; | |
this._updatePlaying(); | |
} | |
get isStalled() { | |
return this._isStalled; | |
} | |
set isStalled(v) { | |
if (this._isStalled === v) { return; } | |
this._isStalled = v; | |
this._updatePlaying(); | |
} | |
get isPlaying() { | |
return !this.isPausing && !this.isStalled && !this.isSeeking; | |
} | |
_updatePlaying() { | |
if (!this._animation) { return; } | |
if (this.isPlaying) { | |
this._animation.play(); | |
} else { | |
this._animation.pause(); | |
} | |
} | |
refresh() { | |
if (!this._isSmoothMode) { return; } | |
if (this._animation) { | |
this._animation.finish(); | |
} | |
this._animation = this._pointer.animate([ | |
{transform: 'translateX(-6px)'}, | |
{transform: `translateX(${global.innerWidth - 6}px)`} | |
], {duration: this._duration * 1000, fill: 'backwards'}); | |
this._animation.currentTime = this._currentTime * 1000; | |
this._animation.playbackRate = this._playbackRate; | |
if (this.isPlaying) { | |
this._animation.play(); | |
} else { | |
this._animation.pause(); | |
} | |
} | |
} | |
class WheelSeeker extends BaseViewComponent { | |
static get template() { | |
return ` | |
<div class="root" style="display: none;"> | |
</div> | |
`; | |
} | |
constructor(params) { | |
super({ | |
parentNode: params.parentNode, | |
name: 'WheelSeeker', | |
template: '<div class="WheelSeeker"></div>', | |
shadow: WheelSeeker.template | |
}); | |
Object.assign(this._props, { | |
watchElement: params.watchElement, | |
isActive: false, | |
pos: 0, | |
ax: 0, | |
lastWheelTime: 0, | |
duration: 1 | |
}); | |
this._bound.onWheel = _.throttle(this.onWheel.bind(this), 50); | |
this._bound.onMouseUp = this.onMouseUp.bind(this); | |
this._bound.dispatchSeek =this.dispatchSeek.bind(this); | |
this._props.watchElement.addEventListener( | |
'wheel', this._bound.onWheel, {passive: false}); | |
} | |
_initDom(...args) { | |
super._initDom(...args); | |
this._elm = Object.assign({}, this._elm, { | |
root: this._shadow || this._view, | |
}); | |
this._shadow.addEventListener('contextmenu', e => { | |
e.stopPropagation(); | |
e.preventDefault(); | |
}); | |
} | |
enable() { | |
document.addEventListener( | |
'mouseup', this._bound.onMouseUp, {capture: true, once: true}); | |
this.refresh(); | |
this.dispatchCommand('wheelSeek-start'); | |
this._elm.root.style.display = ''; | |
this._props.isActive = true; | |
this._props.ax = 0; | |
this._props.lastWheelTime = performance.now(); | |
} | |
disable() { | |
document.removeEventListener('mouseup', this._bound.onMouseUp); | |
this.dispatchCommand('wheelSeek-end'); | |
this.dispatchCommand('seek', this.currentTime); | |
this._props.isActive = false; | |
setTimeout(() => { | |
this._elm.root.style.display = 'none'; | |
}, 300); | |
} | |
onWheel(e) { | |
let {buttons, deltaY} = e; | |
if (!deltaY) { return; } | |
deltaY = Math.abs(deltaY) >= 100 ? deltaY / 50 : deltaY; | |
if (this.isActive) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
if (!buttons && !e.shiftKey) { | |
return this.disable(); | |
} | |
let pos = this._props.pos; | |
let ax = this._props.ax; | |
const deltaReversed = ax * deltaY < 0 ;//lastDelta * deltaY < 0; | |
const now = performance.now(); | |
const seconds = ((now - this._props.lastWheelTime) / 1000); | |
this._props.lastWheelTime = now; | |
if (deltaReversed) { | |
ax = deltaY > 0 ? 0.5 : -0.5; | |
} else { | |
ax = | |
ax * | |
Math.pow(1.15, Math.abs(deltaY)) * // speedup | |
Math.pow(0.8, Math.floor(seconds/0.1)) // speeddown | |
; | |
ax = Math.min(20, Math.abs(ax)) * (ax > 0 ? 1: -1); | |
pos += ax; // / 100; | |
} | |
pos = Math.min(100, Math.max(0, pos)); | |
this._props.ax = ax; | |
this.pos = pos; | |
this._bound.dispatchSeek(); | |
} else if (buttons || e.shiftKey) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.enable(); | |
this._props.ax = deltaY > 0 ? 0.5 : -0.5; | |
} | |
} | |
onMouseUp(e) { | |
if (!this.isActive) { return; } | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.disable(); | |
} | |
dispatchSeek() { | |
this.dispatchCommand('wheelSeek', this.currentTime); | |
} | |
refresh() { | |
} | |
get isActive() { | |
return this._props.isActive; | |
} | |
get duration() { | |
return this._props.duration; | |
} | |
set duration(v) { | |
this._props.duration = v; | |
} | |
get pos() { | |
return this._props.pos; | |
} | |
set pos(v) { | |
this._props.pos = v; | |
if (this.isActive) { | |
this.refresh(); | |
} | |
} | |
get currentTime() { | |
return this.duration * this.pos / 100; | |
} | |
set currentTime(v) { | |
this.pos = v / this.duration * 100; | |
} | |
} | |
function NicoTextParserInitFunc() { | |
class NicoTextParser {} | |
NicoTextParser._FONT_REG = { | |
/* eslint-disable */ | |
GOTHIC: /[\uFF67-\uFF9D\uFF9E\uFF65\uFF9F]/, | |
MINCHO: /([\u02C9\u2105\u2109\u2196-\u2199\u220F\u2215\u2248\u2264\u2265\u2299\u2474-\u2482\u250D\u250E\u2511\u2512\u2515\u2516\u2519\u251A\u251E\u251F\u2521\u2522\u2526\u2527\u2529\u252A\u252D\u252E\u2531\u2532\u2535\u2536\u2539\u253A\u253D\u253E\u2540\u2541\u2543-\u254A\u2550-\u256C\u2584\u2588\u258C\u2593\u01CE\u0D00\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0251\u0261\u02CA\u02CB\u2016\u2035\u216A\u216B\u2223\u2236\u2237\u224C\u226E\u226F\u2295\u2483-\u249B\u2504-\u250B\u256D-\u2573\u2581-\u2583\u2585-\u2586\u2589-\u258B\u258D-\u258F\u2594\u2595\u25E2-\u25E5\u2609\u3016\u3017\u301E\u3021-\u3029\u3105-\u3129\u3220-\u3229\u32A3\u33CE\u33D1\u33D2\u33D5\uE758-\uE864\uFA0C\uFA0D\uFE30\uFE31\uFE33-\uFE44\uFE49-\uFE52\uFE54-\uFE57\uFE59-\uFE66\uFE68-\uFE6B])/, | |
GULIM: /([\u0126\u0127\u0132\u0133\u0138\u013F\u0140\u0149-\u014B\u0166\u0167\u02D0\u02DA\u2074\u207F\u2081-\u2084\u2113\u2153\u2154\u215C-\u215E\u2194-\u2195\u223C\u249C-\u24B5\u24D0-\u24E9\u2592\u25A3-\u25A9\u25B6\u25B7\u25C0\u25C1\u25C8\u25D0\u25D1\u260E\u260F\u261C\u261E\u2660\u2661\u2663-\u2665\u2667-\u2669\u266C\u3131-\u318E\u3200-\u321C\u3260-\u327B\u3380-\u3384\u3388-\u338D\u3390-\u339B\u339F\u33A0\u33A2-\u33CA\u33CF\u33D0\u33D3\u33D6\u33D8\u33DB-\u33DD\uF900-\uF928\uF92A-\uF994\uF996-\uFA0B\uFFE6])/, | |
MING_LIU: /([\uEF00-\uEF1F])/, | |
GR: /<group>([^\x01-\x7E^\xA0]*?([\uFF67-\uFF9D\uFF9E\uFF65\uFF9F\u02C9\u2105\u2109\u2196-\u2199\u220F\u2215\u2248\u2264\u2265\u2299\u2474-\u2482\u250D\u250E\u2511\u2512\u2515\u2516\u2519\u251A\u251E\u251F\u2521\u2522\u2526\u2527\u2529\u252A\u252D\u252E\u2531\u2532\u2535\u2536\u2539\u253A\u253D\u253E\u2540\u2541\u2543-\u254A\u2550-\u256C\u2584\u2588\u258C\u2593\u0126\u0127\u0132\u0133\u0138\u013F\u0140\u0149-\u014B\u0166\u0167\u02D0\u02DA\u2074\u207F\u2081-\u2084\u2113\u2153\u2154\u215C-\u215E\u2194-\u2195\u223C\u249C-\u24B5\u24D0-\u24E9\u2592\u25A3-\u25A9\u25B6\u25B7\u25C0\u25C1\u25C8\u25D0\u25D1\u260E\u260F\u261C\u261E\u2660\u2661\u2663-\u2665\u2667-\u2669\u266C\u3131-\u318E\u3200-\u321C\u3260-\u327B\u3380-\u3384\u3388-\u338D\u3390-\u339B\u339F\u33A0\u33A2-\u33CA\u33CF\u33D0\u33D3\u33D6\u33D8\u33DB-\u33DD\uF900-\uF928\uF92A-\uF994\uF996-\uFA0B\uFFE6\uEF00-\uEF1F\u01CE\u0D00\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0251\u0261\u02CA\u02CB\u2016\u2035\u216A\u216B\u2223\u2236\u2237\u224C\u226E\u226F\u2295\u2483-\u249B\u2504-\u250B\u256D-\u2573\u2581-\u2583\u2585-\u2586\u2589-\u258B\u258D-\u258F\u2594\u2595\u25E2-\u25E5\u2609\u3016\u3017\u301E\u3021-\u3029\u3105-\u3129\u3220-\u3229\u32A3\u33CE\u33D1\u33D2\u33D5\uE758-\uE864\uFA0C\uFA0D\uFE30\uFE31\uFE33-\uFE44\uFE49-\uFE52\uFE54-\uFE57\uFE59-\uFE66\uFE68-\uFE6B])[^\x01-\x7E^\xA0]*?)<\/group>/g, | |
STRONG_MINCHO: /([\u01CE\u0D00\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0251\u0261\u02CA\u02CB\u2016\u2035\u216A\u216B\u2223\u2236\u2237\u224C\u226E\u226F\u2295\u2483-\u249B\u2504-\u250B\u256D-\u2573\u2581-\u2583\u2585-\u2586\u2589-\u258B\u258D-\u258F\u2594\u2595\u25E2-\u25E5\u2609\u3016\u3017\u301E\u3021-\u3029\u3105-\u3129\u3220-\u3229\u32A3\u33CE\u33D1\u33D2\u33D5\uE758-\uE864\uFA0C\uFA0D\uFE30\uFE31\uFE33-\uFE44\uFE49-\uFE52\uFE54-\uFE57\uFE59-\uFE66\uFE68-\uFE6B\u2588])/, | |
BLOCK: /([\u2581-\u258F\u25E2-\u25E5■]+)/g, | |
/* eslint-enable */ | |
}; | |
NicoTextParser.__css__ = (` | |
body { | |
marign: 0; | |
padding: 0; | |
overflow: hidden; | |
pointer-events: none; | |
user-select: none; | |
} | |
.default {} | |
.gothic {font-family: 'MS Pゴシック', 'IPAMonaPGothic', sans-serif, Arial, 'Menlo'; } | |
.mincho {font-family: Simsun, "Osaka−等幅", 'MS 明朝', 'MS ゴシック', 'モトヤLシーダ3等幅', 'Hiragino Mincho ProN'; } | |
.gulim {font-family: Gulim, Osaka-mono, "Osaka−等幅", 'MS ゴシック', 'モトヤLシーダ3等幅'; } | |
.mingLiu {font-family: PmingLiu, mingLiu, MingLiU, Osaka-mono, "Osaka−等幅", 'MS 明朝', 'MS ゴシック', 'モトヤLシーダ3等幅'; } | |
han_group { font-family: 'Arial'; } | |
/* 参考: https://www65.atwiki.jp/commentart2/pages/16.html */ | |
.cmd-gothic { | |
font-weight: 400; | |
font-family: "游ゴシック", "Yu Gothic", 'YuGothic', Simsun, "MS ゴシック", "IPAMonaPGothic", sans-serif, Arial, Menlo;} | |
.cmd-mincho { | |
font-weight: 400; | |
font-family: "游明朝体", "Yu Mincho", 'YuMincho', Simsun, "Osaka−等幅", "MS 明朝", "MS ゴシック", "モトヤLシーダ3等幅", 'Hiragino Mincho ProN', monospace; | |
} | |
.cmd-defont { | |
font-family: arial, "MS Pゴシック", "MS PGothic", "MSPGothic", "ヒラギノ角ゴ", "ヒラギノ角ゴシック", "Hiragino Sans", "IPAMonaPGothic", sans-serif, monospace, Menlo; | |
} | |
.nicoChat { | |
position: absolute; | |
letter-spacing: 1px; | |
padding: 2px 0 2px; | |
margin: 0; | |
white-space: nowrap; | |
/*font-weight: 600; | |
-webkit-font-smoothing: none; | |
font-smooth: never;*/ | |
/* text-rendering: optimizeSpeed; */ | |
/*font-kerning: none;*/ | |
} | |
.nicoChat.big { | |
line-height: 45px; | |
} | |
.nicoChat.big.html5 { | |
line-height: ${47.5 -1}px; | |
} | |
.nicoChat.big.is-lineResized { | |
line-height: ${48}px; | |
} | |
.nicoChat.medium { | |
line-height: 29px; | |
} | |
.nicoChat.medium.html5 { | |
line-height: ${(384 - 4) / 13}px; | |
} | |
.nicoChat.medium.is-lineResized { | |
line-height: ${(384 - 4) * 2 / 25 -0.4}px; | |
} | |
.nicoChat.small { | |
line-height: 18px; | |
} | |
.nicoChat.small.html5 { | |
line-height: ${(384 - 4) / 21}px; | |
} | |
.nicoChat.small.is-lineResized { | |
line-height: ${(384 - 4) * 2 / 38}px; | |
} | |
.arial.type2001 { | |
font-family: Arial; | |
} | |
/* フォント変化のあったグループの下にいるということは、 | |
半角文字に挟まれていないはずである。 | |
*/ | |
.gothic > .type2001 { | |
font-family: 'MS Pゴシック', 'IPAMonaPGothic', sans-serif, Arial, 'Menlo'; | |
} | |
.mincho > .type2001 { | |
font-family: Simsun, Osaka-mono, 'MS 明朝', 'MS ゴシック', 'モトヤLシーダ3等幅', monospace | |
} | |
.gulim > .type2001 { | |
font-family: Gulim, Osaka-mono, 'MS ゴシック', 'モトヤLシーダ3等幅', monospace; | |
} | |
.mingLiu > .type2001 { | |
font-family: PmingLiu, mingLiu, Osaka-mono, 'MS 明朝', 'MS ゴシック', 'モトヤLシーダ3等幅', monospace; | |
} | |
/* | |
.tab_space { opacity: 0; } | |
.big .tab_space > spacer { width: 86.55875px; } | |
.medium .tab_space > spacer { width: 53.4px; } | |
.small .tab_space > spacer { width: 32.0625px; } | |
*/ | |
.tab_space { font-family: 'Courier New', Osaka-mono, 'MS ゴシック', monospace; opacity: 0 !important; } | |
.big .tab_space { letter-spacing: 1.6241em; } | |
.medium .tab_space { letter-spacing: 1.6252em; } | |
.small .tab_space { letter-spacing: 1.5375em; } | |
.big .type0020 > spacer { width: 11.8359375px; } | |
.medium .type0020 > spacer { width: 7.668px; } | |
.small .type0020 > spacer { width: 5px; } | |
/* | |
.big .type3000 > spacer { width: 40px; } | |
.medium .type3000 > spacer { width: 25px; } | |
.small .type3000 > spacer { width: 17px; } | |
*/ | |
/* | |
.type3000 > spacer::after { content: ' '; } | |
.mincho > .type3000 > spacer::after, .gulim > .type3000 > spacer::after, .mincho > .type3000 > spacer::after { | |
content: '全'; | |
} | |
*/ | |
.big .gothic > .type3000 > spacer { width: 26.8984375px; } | |
.medium .gothic > .type3000 > spacer { width: 16.9375px; } | |
.small .gothic > .type3000 > spacer { width: 10.9609375px; } | |
.big .type00A0 > spacer { width: 11.8359375px; } | |
.medium .type00A0 > spacer { width: 7.668px; } | |
.small .type00A0 > spacer { width: 5px; } | |
spacer { display: inline-block; overflow: hidden; margin: 0; padding: 0; height: 8px; vertical-align: middle;} | |
.mesh_space { | |
display: inline-block; overflow: hidden; margin: 0; padding: 0; letter-spacing: 0; | |
vertical-align: middle; font-weight: normal; | |
white-space: nowrap; | |
} | |
.big .mesh_space { width: 40px; } | |
.medium .mesh_space { width: 26px; } | |
.small .mesh_space { width: 18px; } | |
/* | |
.fill_space { | |
display: inline-block; overflow: hidden; margin: 0; padding: 0; letter-spacing: 0; | |
vertical-align: bottom; font-weight: normal; | |
white-space: nowrap; | |
} | |
.big .fill_space { width: 40px; height: 40px; } | |
.medium .fill_space { width: 25px; height: 25px; } | |
.small .fill_space { width: 16px; height: 16px; } | |
*/ | |
.backslash { | |
font-family: Arial; | |
} | |
/* Mac Chrome バグ対策? 空白文字がなぜか詰まる これでダメならspacer作戦 */ | |
.invisible_code { | |
font-family: gulim; | |
} | |
.block_space { | |
font-family: Simsun, 'IPAMonaGothic', Gulim, PmingLiu; | |
} | |
.html5_tab_space, .html5_space, .html5_zen_space { opacity: 0; } | |
/* | |
.nicoChat.small .html5_zen_space > spacer { width: 25.6px; } | |
.html5_zen_space > spacer { width: 25.6px; margin: 0; } | |
.nicoChat.big .html5_zen_space > spacer { width: 25.6px; } | |
*/ | |
.html5_zero_width { display: none; } | |
.no-height { | |
line-height: 0 !important; | |
opacity: 0; | |
display: block; | |
visibility: hidden; | |
} | |
/* .line53 { | |
display: inline-block; | |
line-height: 32px; | |
} | |
.line100 { | |
display: inline-block; | |
line-height: 23.5px; | |
}*/ | |
/*.line70 { | |
display: inline-block; | |
line-height: 27px; | |
}*/ | |
`).trim(); | |
NicoTextParser.likeXP = text => { | |
let S = '<spacer> </spacer>'; | |
let ZS = '<spacer>全</spacer>'; | |
let htmlText = | |
util.escapeHtml(text) | |
.replace(/([\x01-\x09\x0B-\x7E\xA0]+)/g, '<han_group>$1</han_group>') // eslint-disable-line | |
.replace(/([^\x01-\x7E^\xA0]+)/g, '<group>$1</group>') // eslint-disable-line | |
.replace(/([\u0020]+)/g, // '<span class="han_space type0020">$1</span>') | |
g => `<span class="han_space type0020">${S.repeat(g.length)}</span>`) | |
.replace(/([\u00A0]+)/g, // '<span class="han_space type00A0">$1</span>') | |
g => `<span class="han_space type00A0">${S.repeat(g.length)}</span>`) | |
.replace(/(\t+)/g, '<span class="tab_space">$1</span>') | |
.replace(/[\t]/g, '^'); | |
let /* hasFontChanged = false, */ strongFont = 'gothic'; | |
htmlText = | |
htmlText.replace(NicoTextParser._FONT_REG.GR, (all, group, firstChar) => { | |
let baseFont = ''; | |
if (firstChar.match(NicoTextParser._FONT_REG.GOTHIC)) { | |
baseFont = 'gothic'; | |
} else if (firstChar.match(NicoTextParser._FONT_REG.MINCHO)) { | |
baseFont = 'mincho'; | |
if (firstChar.match(NicoTextParser._FONT_REG.STRONG_MINCHO)) { | |
strongFont = 'mincho'; | |
} | |
} else if (firstChar.match(NicoTextParser._FONT_REG.GULIM)) { | |
strongFont = baseFont = 'gulim'; | |
} else { | |
strongFont = baseFont = 'mingLiu'; | |
} | |
let tmp = [], closer = [], currentFont = baseFont; | |
for (let i = 0, len = group.length; i < len; i++) { | |
let c = group.charAt(i); | |
if (currentFont !== 'gothic' && c.match(NicoTextParser._FONT_REG.GOTHIC)) { | |
tmp.push('<span class="gothic">'); | |
closer.push('</span>'); | |
currentFont = 'gothic'; | |
} else if (currentFont !== 'mincho' && c.match(NicoTextParser._FONT_REG.MINCHO)) { | |
tmp.push('<span class="mincho">'); | |
closer.push('</span>'); | |
currentFont = 'mincho'; | |
if (c.match(NicoTextParser._FONT_REG.STRONG_MINCHO)) { | |
strongFont = baseFont = 'mincho'; | |
} | |
} else if (currentFont !== 'gulim' && c.match(NicoTextParser._FONT_REG.GULIM)) { | |
tmp.push('<span class="gulim">'); | |
closer.push('</span>'); | |
currentFont = strongFont = baseFont = 'gulim'; | |
} else if (currentFont !== 'mingLiu' && c.match(NicoTextParser._FONT_REG.MING_LIU)) { | |
tmp.push('<span class="mingLiu">'); | |
closer.push('</span>'); | |
currentFont = strongFont = baseFont = 'mingLiu'; | |
} | |
tmp.push(c); | |
} | |
let result = [ | |
'<group class="', baseFont, ' fontChanged">', | |
tmp.join(''), | |
closer.join(''), | |
'</group>' | |
].join(''); | |
return result; | |
}); | |
htmlText = | |
htmlText | |
.replace(NicoTextParser._FONT_REG.BLOCK, '<span class="block_space">$1</span>') | |
.replace(/([\u2588]+)/g, //'<span class="fill_space">$1</span>') | |
g => `<span class="fill_space">${'田'.repeat(g.length)}</span>`) | |
.replace(/([\u2592])/g, '<span class="mesh_space">$1$1</span>') | |
.replace(/([\uE800\u2002-\u200A\u007F\u05C1\u0E3A\u3164]+)/g, | |
g => `<span class="invisible_code" data-code="${escape(g)}">${g}</span>`) | |
.replace(/(.)[\u0655]/g, '$1<span class="type0655">$1</span>') | |
.replace(/([\u115a]+)/g, '<span class="zen_space type115A">$1</span>') | |
.replace(/([\u3000]+)/g, //'<span class="zen_space type3000">$1</span>') | |
g => `<span class="zen_space type3000">${ZS.repeat(g.length)}</span>`) | |
.replace(/\\/g, '<span lang="en" class="backslash">\</span>') | |
.replace(/([\u0323\u2029\u202a\u200b\u200c]+)/g, '<span class="zero_space">$1</span>') | |
.replace(/([\u2003]+)/g, '<span class="em_space">$1</span>') | |
.replace(/\r\n/g, '\n').replace(/([^\n])[\n]$/, '$1') //.replace(/^[\r\n]/, '') | |
.replace(/[\n]/g, '<br>') | |
; | |
htmlText = htmlText.replace(/(.)<group>([\u2001]+)<\/group>(.)/, '$1<group class="zen_space arial type2001">$2</group>$3'); | |
htmlText = htmlText.replace(/<group>/g, `<group class="${strongFont}">`); | |
return htmlText; | |
}; | |
NicoTextParser.likeHTML5 = text => { | |
let htmlText = | |
util.escapeHtml(text) | |
.replace(/([\x20\xA0]+)/g, g => { | |
return `<span class="html5_space" data-text="${encodeURIComponent(g)}">${' '.repeat(g.length)}</span>`; | |
}) | |
.replace(/([\u2000\u2002]+)/g, g => { | |
return `<span class="html5_space half" data-text="${encodeURIComponent(g)}">${g}</span>`; | |
}) | |
.replace(/([\u3000\u2001\u2003]+)/g, g => { | |
return `<span class="html5_zen_space" data-text="${encodeURIComponent(g)}">${'全'.repeat(g.length)}</span>`; | |
}) | |
.replace(/[\u200B-\u200F]+/g, g => { | |
return `<span class="html5_zero_width" data-text="${encodeURIComponent(g)}">${g}</span>`; | |
}) | |
.replace(/([\t]+)/g, g => { | |
return '<span class="html5_tab_space">' + | |
'丁'.repeat(g.length * 2) + '</span>'; | |
}) | |
.replace(NicoTextParser._FONT_REG.BLOCK, '<span class="html5_block_space">$1</span>') | |
.replace(/([\u2588]+)/g, g => { | |
return '<span class="html5_fill_space u2588">' + //g + '</span>'; | |
'田'.repeat(g.length) + '</span>'; | |
}) | |
.replace(/[\n]/g, '<br>') | |
; | |
let sp = htmlText.split('<br>'); | |
if (sp.length >= 101) { | |
htmlText = `<span class="line101">${sp.slice(0, 101).join('<br>')}</span><span class="no-height">${sp.slice(101).join('<br>')}</span>`; | |
} else if (sp.length >= 70) { | |
htmlText = `<span class="line70">${sp.slice(0, 70).join('<br>')}</span><span class="no-height">${sp.slice(70).join('<br>')}</span>`; | |
} else if (sp.length >= 53) { | |
htmlText = `<span class="line53">${sp.slice(0,53).join('<br>')}</span><span class="no-height">${sp.slice(53).join('<br>')}</span>`; | |
} | |
return htmlText; | |
}; | |
return NicoTextParser; | |
} | |
const NicoTextParser = NicoTextParserInitFunc(); | |
ZenzaWatch.NicoTextParser = NicoTextParser; | |
class CommentLayer { | |
} | |
CommentLayer.SCREEN = { | |
WIDTH_INNER: 512, | |
WIDTH_FULL_INNER: 640, | |
WIDTH_FULL_INNER_HTML5: 684, | |
WIDTH: 512 + 32, | |
WIDTH_FULL: 640 + 32, | |
OUTER_WIDTH_FULL: (640 + 32) * 1.1, | |
HEIGHT: 384 | |
}; | |
CommentLayer.MAX_COMMENT = 10000; | |
function NicoChatInitFunc() { | |
class NicoChat { | |
static createBlank(options = {}) { | |
return Object.assign({ | |
text: '', | |
date: '000000000', | |
cmd: '', | |
premium: false, | |
user_id: '0', | |
vpos: 0, | |
deleted: '', | |
color: '#FFFFFF', | |
size: NicoChat.SIZE.MEDIUM, | |
type: NicoChat.TYPE.NAKA, | |
score: 0, | |
no: 0, | |
fork: 0, | |
isInvisible: false, | |
isReverse: false, | |
isPatissier: false, | |
fontCommand: '', | |
commentVer: 'flash', | |
currentTime: 0, | |
hasDurationSet: false, | |
isMine: false, | |
isUpdating: false, | |
isCA: false, | |
thread: 0, | |
nicoru: 0, | |
opacity: 1 | |
}, options); | |
} | |
static create(data, options = {}) { | |
return new NicoChat(NicoChat.createBlank(data), options); | |
} | |
static createFromChatElement(elm, options = {}) { | |
const data = { | |
text: elm.textContent, | |
date: parseInt(elm.getAttribute('date'), 10) || Math.floor(Date.now() / 1000), | |
cmd: elm.getAttribute('mail') || '', | |
isPremium: elm.getAttribute('premium') === '1', | |
userId: elm.getAttribute('user_id'), | |
vpos: parseInt(elm.getAttribute('vpos'), 10), | |
deleted: elm.getAttribute('deleted') === '1', | |
isMine: elm.getAttribute('mine') === '1', | |
isUpdating: elm.getAttribute('updating') === '1', | |
score: parseInt(elm.getAttribute('score') || '0', 10), | |
fork: parseInt(elm.getAttribute('fork') || '0', 10), | |
leaf: parseInt(elm.getAttribute('leaf') || '-1', 10), | |
no: parseInt(elm.getAttribute('no') || '0', 10), | |
thread: parseInt(elm.getAttribute('thread'), 10) | |
}; | |
return new NicoChat(data, options); | |
} | |
static parseCmd(command, isFork = false, props = {}) { | |
const tmp = command.toLowerCase().split(/[\x20\xA0\u3000\t\u2003\s]+/); | |
const cmd = {}; | |
for (const c of tmp) { | |
if (NicoChat.COLORS[c]) { | |
cmd.COLOR = NicoChat.COLORS[c]; | |
} else if (NicoChat._COLOR_MATCH.test(c)) { | |
cmd.COLOR = c; | |
} else if (isFork && NicoChat._CMD_DURATION.test(c)) { | |
cmd.duration = RegExp.$1; | |
} else { | |
cmd[c] = true; | |
} | |
} | |
if (cmd.COLOR) { | |
props.color = cmd.COLOR; | |
props.hasColorCommand = true; | |
} | |
if (cmd.big) { | |
props.size = NicoChat.SIZE.BIG; | |
props.hasSizeCommand = true; | |
} else if (cmd.small) { | |
props.size = NicoChat.SIZE.SMALL; | |
props.hasSizeCommand = true; | |
} | |
if (cmd.ue) { | |
props.type = NicoChat.TYPE.TOP; | |
props.duration = NicoChat.DURATION.TOP; | |
props.hasTypeCommand = true; | |
} else if (cmd.shita) { | |
props.type = NicoChat.TYPE.BOTTOM; | |
props.duration = NicoChat.DURATION.BOTTOM; | |
props.hasTypeCommand = true; | |
} | |
if (cmd.ender) { | |
props.isEnder = true; | |
} | |
if (cmd.full) { | |
props.isFull = true; | |
} | |
if (cmd.pattisier) { | |
props.isPatissier = true; | |
} | |
if (cmd.ca) { | |
props.isCA = true; | |
} | |
if (cmd.duration) { | |
props.hasDurationSet = true; | |
props.duration = Math.max(0.01, parseFloat(cmd.duration, 10)); | |
} | |
if (cmd.mincho) { | |
props.fontCommand = 'mincho'; | |
props.commentVer = 'html5'; | |
} else if (cmd.gothic) { | |
props.fontCommand = 'gothic'; | |
props.commentVer = 'html5'; | |
} else if (cmd.defont) { | |
props.fontCommand = 'defont'; | |
props.commentVer = 'html5'; | |
} | |
if (cmd._live) { | |
props.opacity *= 0.5; | |
} | |
return props; | |
} | |
static SORT_FUNCTION(a, b) { | |
const av = a.vpos, bv = b.vpos; | |
if (av !== bv) { | |
return av - bv; | |
} else { | |
return a.uniqNo < b.uniqNo ? -1 : 1; | |
} | |
} | |
constructor(data, options = {}) { | |
options = Object.assign({videoDuration: 0x7FFFFF, mainThreadId: 0, format: ''}, options); | |
const props = this.props = {}; | |
props.id = `chat${NicoChat.id++}`; | |
props.currentTime = 0; | |
Object.assign(props, data); | |
if (options.format === 'bulk') { | |
return; | |
} | |
props.userId = data.user_id; | |
props.fork = data.fork * 1; | |
props.thread = data.thread * 1; | |
props.isPremium = data.premium ? '1' : '0'; | |
props.isSubThread = (options.mainThreadId && props.thread !== options.mainThreadId); | |
props.layerId = typeof data.layerId === 'number' ? | |
data.layerId : (props.fork*1 % 2 /* fork2を0と同じレイヤーにするダメ対応. fork3とか4とか来たらまた考える */); | |
props.uniqNo = | |
(data.no % 10000) + | |
(data.fork * 100000) + | |
((data.thread % 1000000) * 1000000); | |
props.color = null; | |
props.size = NicoChat.SIZE.MEDIUM; | |
props.type = NicoChat.TYPE.NAKA; | |
props.duration = NicoChat.DURATION.NAKA; | |
props.commentVer = 'flash'; | |
props.nicoru = data.nicoru || 0; | |
props.valhalla = data.valhalla; | |
props.lastNicoruDate = data.last_nicoru_date || null; | |
props.opacity = 1; | |
props.time3d = 0; | |
props.time3dp = 0; | |
const text = props.text; | |
if (props.fork > 0 && text.match(/^[/@@]/)) { | |
props.isNicoScript = true; | |
props.isInvisible = true; | |
} | |
if (props.deleted) { | |
return; | |
} | |
const cmd = props.cmd; | |
if (cmd.length > 0 && cmd.trim() !== '184') { | |
NicoChat.parseCmd(cmd, props.fork > 0, props); | |
} | |
const maxv = | |
props.isNicoScript ? | |
Math.min(props.vpos, options.videoDuration * 100) : | |
Math.min(props.vpos, | |
(1 + options.videoDuration - props.duration) * 100 + | |
Math.random() * 40 - 20); | |
const minv = Math.max(maxv, 0); | |
props.vpos = minv; | |
} | |
reset () { | |
Object.assign(this.props, { | |
text: '', | |
date: '000000000', | |
cmd: '', | |
isPremium: false, | |
userId: '', | |
vpos: 0, | |
deleted: '', | |
color: '#FFFFFF', | |
size: NicoChat.SIZE.MEDIUM, | |
type: NicoChat.TYPE.NAKA, | |
isMine: false, | |
score: 0, | |
no: 0, | |
fork: 0, | |
isInvisible: false, | |
isReverse: false, | |
isPatissier: false, | |
fontCommand: '', | |
commentVer: 'flash', | |
nicoru: 0, | |
currentTime: 0, | |
hasDurationSet: false | |
}); | |
} | |
onChange () { | |
if (this.props.group) { | |
this.props.group.onChange({chat: this}); | |
} | |
} | |
set currentTime(sec) { this.props.currentTime = sec;} | |
get currentTime() { return this.props.currentTime;} | |
set group(group) {this.props.group = group;} | |
get group() { return this.props.group;} | |
get isUpdating() { return !!this.props.isUpdating; } | |
set isUpdating(v) { | |
if (this.props.isUpdating !== v) { | |
this.props.isUpdating = !!v; | |
if (!v) { | |
this.onChange(); | |
} | |
} | |
} | |
set isPostFail(v) {this.props.isPostFail = v;} | |
get isPostFail() {return !!this.props.isPostFail;} | |
get id() {return this.props.id;} | |
get text() {return this.props.text;} | |
set text(v) { | |
this.props.text = v; | |
this.props.htmlText = null; | |
} | |
get htmlText() {return this.props.htmlText || '';} | |
set htmlText(v) { this.props.htmlText = v; } | |
get date() {return this.props.date;} | |
get dateUsec() {return this.props.date_usec;} | |
get lastNicoruDate() {return this.props.lastNicoruDate;} | |
get cmd() {return this.props.cmd;} | |
get isPremium() {return !!this.props.isPremium;} | |
get isEnder() {return !!this.props.isEnder;} | |
get isFull() {return !!this.props.isFull;} | |
get isMine() {return !!this.props.isMine;} | |
get isInvisible() {return this.props.isInvisible;} | |
get isNicoScript() {return this.props.isNicoScript;} | |
get isPatissier() {return this.props.isPatissier;} | |
get isSubThread() {return this.props.isSubThread;} | |
get hasColorCommand() {return !!this.props.hasColorCommand;} | |
get hasSizeCommand() {return !!this.props.hasSizeCommand;} | |
get hasTypeCommand() {return !!this.props.hasTypeCommand;} | |
get duration() {return this.props.duration;} | |
get hasDurationSet() {return !!this.props.hasDurationSet;} | |
set duration(v) { | |
this.props.duration = v; | |
this.props.hasDurationSet = true; | |
} | |
get userId() {return this.props.userId;} | |
get vpos() {return this.props.vpos;} | |
get beginTime() {return this.vpos / 100;} | |
get isDeleted() {return !!this.props.deleted;} | |
get color() {return this.props.color;} | |
set color(v) {this.props.color = v;} | |
get size() {return this.props.size;} | |
set size(v) {this.props.size = v;} | |
get type() {return this.props.type;} | |
set type(v) {this.props.type = v;} | |
get score() {return this.props.score;} | |
get no() {return this.props.no;} | |
set no(no) { | |
const props = this.props; | |
props.no = no; | |
props.uniqNo = | |
(no % 100000) + | |
(props.fork * 1000000) + | |
(props.thread * 10000000); | |
} | |
get uniqNo() {return this.props.uniqNo;} | |
get layerId() {return this.props.layerId;} | |
get leaf() {return this.props.leaf;} | |
get fork() {return this.props.fork;} | |
get isReverse() {return this.props.isReverse;} | |
set isReverse(v) {this.props.isReverse = !!v;} | |
get fontCommand() {return this.props.fontCommand;} | |
get commentVer() {return this.props.commentVer;} | |
get threadId() {return this.props.thread;} | |
get nicoru() {return this.props.nicoru;} | |
set nicoru(v) {this.props.nicoru = v;} | |
get nicotta() { return !!this.props.nicotta;} | |
set nicotta(v) { this.props.nicotta = v; | |
} | |
get opacity() {return this.props.opacity;} | |
get valhalla() {return this.props.valhalla || 0; } | |
} | |
NicoChat.id = 1000000; | |
NicoChat.SIZE = { | |
BIG: 'big', | |
MEDIUM: 'medium', | |
SMALL: 'small' | |
}; | |
NicoChat.TYPE = { | |
TOP: 'ue', | |
NAKA: 'naka', | |
BOTTOM: 'shita' | |
}; | |
NicoChat.DURATION = { | |
TOP: 3 - 0.1, | |
NAKA: 4, | |
BOTTOM: 3 - 0.1 | |
}; | |
NicoChat._CMD_DURATION = /[@@]([0-9.]+)/; | |
NicoChat._CMD_REPLACE = /(ue|shita|sita|big|small|ender|full|[ ])/g; | |
NicoChat._COLOR_MATCH = /(#[0-9a-f]+)/i; | |
NicoChat._COLOR_NAME_MATCH = /([a-z]+)/i; | |
NicoChat.COLORS = { | |
'red': '#FF0000', | |
'pink': '#FF8080', | |
'orange': '#FFC000', | |
'yellow': '#FFFF00', | |
'green': '#00FF00', | |
'cyan': '#00FFFF', | |
'blue': '#0000FF', | |
'purple': '#C000FF', | |
'black': '#000000', | |
'white2': '#CCCC99', | |
'niconicowhite': '#CCCC99', | |
'red2': '#CC0033', | |
'truered': '#CC0033', | |
'pink2': '#FF33CC', | |
'orange2': '#FF6600', | |
'passionorange': '#FF6600', | |
'yellow2': '#999900', | |
'madyellow': '#999900', | |
'green2': '#00CC66', | |
'elementalgreen': '#00CC66', | |
'cyan2': '#00CCCC', | |
'blue2': '#3399FF', | |
'marineblue': '#3399FF', | |
'purple2': '#6633CC', | |
'nobleviolet': '#6633CC', | |
'black2': '#666666' | |
}; | |
return NicoChat; | |
} // worker用 | |
const NicoChat = NicoChatInitFunc(); | |
class NicoChatViewModel { | |
static create(nicoChat, offScreen) { | |
if (nicoChat.commentVer === 'html5') { | |
return new HTML5NicoChatViewModel(nicoChat, offScreen); | |
} | |
return new FlashNicoChatViewModel(nicoChat, offScreen); | |
} | |
constructor(nicoChat, offScreen) { | |
this._speedRate = NicoChatViewModel.SPEED_RATE; | |
this.initialize(nicoChat, offScreen); | |
if (this._height >= CommentLayer.SCREEN.HEIGHT - this._fontSizePixel / 2) { | |
this._isOverflow = true; | |
} | |
let cssLineHeight = this._cssLineHeight; | |
this._cssScaleY = cssLineHeight / Math.floor(cssLineHeight); | |
this._cssLineHeight = Math.floor(cssLineHeight); | |
if (this._isOverflow || nicoChat.isInvisible) { | |
this.checkCollision = () => { | |
return false; | |
}; | |
} | |
} | |
initialize(nicoChat, offScreen) { | |
this._nicoChat = nicoChat; | |
this._offScreen = offScreen; | |
this._isOverflow = false; | |
this._duration = nicoChat.duration; | |
this._isFixed = false; | |
this._scale = NicoChatViewModel.BASE_SCALE; | |
this._cssLineHeight = 29; | |
this._cssScaleY = 1; | |
this._y = 0; | |
this._slot = -1; | |
this.setType(nicoChat.type); | |
this.setVpos(nicoChat.vpos); | |
this.setSize(nicoChat.size, nicoChat.commentVer); | |
this._isLayouted = false; | |
this.setText(nicoChat.text, nicoChat.htmlText); | |
if (this._isFixed) { | |
this._setupFixedMode(); | |
} else { | |
this._setupMarqueeMode(); | |
} | |
} | |
setType(type) { | |
this._type = type; | |
switch(type) { | |
case NicoChat.TYPE.TOP: | |
this._isFixed = true; | |
break; | |
case NicoChat.TYPE.BOTTOM: | |
this._isFixed = true; | |
break; | |
} | |
} | |
setVpos(vpos) { | |
switch (this._type) { | |
case NicoChat.TYPE.TOP: | |
this._beginLeftTiming = vpos / 100; | |
break; | |
case NicoChat.TYPE.BOTTOM: | |
this._beginLeftTiming = vpos / 100; | |
break; | |
default: | |
this._beginLeftTiming = vpos / 100 - 1; | |
break; | |
} | |
this._endRightTiming = this._beginLeftTiming + this._duration; | |
} | |
setSize(size) { | |
this._size = size; | |
const SIZE_PIXEL = this._nicoChat.commentVer === 'html5' ? | |
NicoChatViewModel.FONT_SIZE_PIXEL_VER_HTML5 : NicoChatViewModel.FONT_SIZE_PIXEL; | |
switch (size) { | |
case NicoChat.SIZE.BIG: | |
this._fontSizePixel = SIZE_PIXEL.BIG; | |
break; | |
case NicoChat.SIZE.SMALL: | |
this._fontSizePixel = SIZE_PIXEL.SMALL; | |
break; | |
default: | |
this._fontSizePixel = SIZE_PIXEL.MEDIUM; | |
break; | |
} | |
} | |
setText(text, parsedHtmlText = '') { | |
const fontCommand = this.fontCommand; | |
const commentVer = this.commentVer; | |
const htmlText = parsedHtmlText || | |
(commentVer === 'html5' ? NicoTextParser.likeHTML5(text) : NicoTextParser.likeXP(text)); | |
this._htmlText = htmlText; | |
this._text = text; | |
const field = this._offScreen.getTextField(); | |
field.setText(htmlText); | |
field.setFontSizePixel(this._fontSizePixel); | |
field.setType(this._type, this._size, fontCommand, this.commentVer); | |
this._originalWidth = field.getOriginalWidth(); | |
this._width = this._originalWidth * this._scale; | |
this._originalHeight = field.getOriginalHeight(); | |
this._height = this._calculateHeight({}); | |
const w = this._width; | |
const duration = this._duration / this._speedRate; | |
if (!this._isFixed) { // 流れるコメント (naka) | |
const speed = | |
this._speed = (w + CommentLayer.SCREEN.WIDTH) / duration; | |
const spw = w / speed; | |
this._endLeftTiming = this._endRightTiming - spw; | |
this._beginRightTiming = this._beginLeftTiming + spw; | |
} else { // ue shita などの固定コメント | |
this._speed = 0; | |
this._endLeftTiming = this._endRightTiming; | |
this._beginRightTiming = this._beginLeftTiming; | |
} | |
} | |
recalcBeginEndTiming(speedRate = 1) { | |
const width = this._width; | |
const duration = this._duration / speedRate; | |
this._endRightTiming = this._beginLeftTiming + duration; | |
this._speedRate = speedRate; | |
if (isNaN(width)) { | |
return; | |
} | |
if (!this._isFixed) { | |
const speed = | |
this._speed = (width + CommentLayer.SCREEN.WIDTH) / duration; | |
const spw = width / speed; | |
this._endLeftTiming = this._endRightTiming - spw; | |
this._beginRightTiming = this._beginLeftTiming + spw; | |
} else { | |
this._speed = 0; | |
this._endLeftTiming = this._endRightTiming; | |
this._beginRightTiming = this._beginLeftTiming; | |
} | |
} | |
_calcLineHeight({size, scale = 1}) { | |
const SIZE = NicoChat.SIZE; | |
const MARGIN = 5; | |
let lineHeight; | |
if (scale >= 0.75) { | |
switch (size) { | |
case SIZE.BIG: | |
lineHeight = (50 - MARGIN * scale) * NicoChatViewModel.BASE_SCALE; | |
break; | |
case SIZE.SMALL: | |
lineHeight = (23 - MARGIN * scale) * NicoChatViewModel.BASE_SCALE; | |
break; | |
default: | |
lineHeight = (34 - MARGIN * scale) * NicoChatViewModel.BASE_SCALE; | |
break; | |
} | |
} else { | |
switch (size) { | |
case SIZE.BIG: | |
lineHeight = (387 - MARGIN * scale * 0.5) / 16 * NicoChatViewModel.BASE_SCALE; | |
break; | |
case SIZE.SMALL: | |
lineHeight = (383 - MARGIN * scale * 0.5) / 38 * NicoChatViewModel.BASE_SCALE; | |
break; | |
default: | |
lineHeight = (378 - MARGIN * scale * 0.5) / 25 * NicoChatViewModel.BASE_SCALE; | |
} | |
} | |
return lineHeight; | |
} | |
_calcDoubleResizedLineHeight({lc = 1, cssScale, size = NicoChat.SIZE.BIG}) { | |
const MARGIN = 5; | |
if (size !== NicoChat.SIZE.BIG) { | |
return (size === NicoChat.SIZE.MEDIUM ? 24 : 13) + MARGIN; | |
} | |
// @see https://www37.atwiki.jp/commentart/pages/20.html | |
cssScale = typeof cssScale === 'number' ? cssScale : this.cssScale; | |
let lineHeight; | |
if (lc <= 9) { | |
lineHeight = ((392 / cssScale) - MARGIN) / lc -1; | |
} else if (lc <= 10) { | |
lineHeight = ((384 / cssScale) - MARGIN) / lc -1; | |
} else if (lc <= 11) { | |
lineHeight = ((389 / cssScale) - MARGIN) / lc -1; | |
} else if (lc <= 12) { | |
lineHeight = ((388 / cssScale) - MARGIN) / lc -1; | |
} else if (lc <= 13) { | |
lineHeight = ((381 / cssScale) - MARGIN) / lc -1; | |
} else { | |
lineHeight = ((381 / cssScale) - MARGIN) / 14; | |
} | |
return lineHeight; | |
} | |
_calculateHeight ({scale = 1, lc = 0, size, isEnder, isDoubleResized}) { | |
lc = lc || this.lineCount; | |
isEnder = typeof isEnder === 'boolean' ? isEnder : this._nicoChat.isEnder; | |
isDoubleResized = typeof isDoubleResized === 'boolean' ? isDoubleResized : this.isDoubleResized; | |
size = size || this._size; | |
const MARGIN = 5; | |
const TABLE_HEIGHT = 385; | |
let lineHeight; | |
if (isDoubleResized) { | |
this._cssLineHeight = this._calcDoubleResizedLineHeight({lc, size}); | |
return (((this._cssLineHeight - MARGIN) * lc) * scale * 0.5 + MARGIN -1) * NicoChatViewModel.BASE_SCALE; | |
} | |
let height; | |
lineHeight = this._calcLineHeight({lc, size, scale}); | |
this._cssLineHeight = lineHeight; | |
height = (lineHeight * lc + MARGIN) * scale; | |
if (lc === 1) { | |
this._isLineResized = false; | |
return height - 1; | |
} | |
if (isEnder || height < TABLE_HEIGHT / 3) { | |
this._isLineResized = false; | |
return height - 1; | |
} | |
this._isLineResized = true; | |
lineHeight = this._calcLineHeight({lc, size, scale: scale * 0.5}); | |
this._cssLineHeight = lineHeight * 2 -1; | |
return (lineHeight * lc + MARGIN) * scale - 1; | |
} | |
_setupFixedMode() { | |
const nicoChat = this._nicoChat; | |
const SCREEN = CommentLayer.SCREEN; | |
let ver = nicoChat.commentVer; | |
let fullWidth = ver === 'html5' ? SCREEN.WIDTH_FULL_INNER_HTML5 : SCREEN.WIDTH_FULL_INNER; | |
let screenWidth = | |
nicoChat.isFull ? fullWidth : SCREEN.WIDTH_INNER; | |
let screenHeight = CommentLayer.SCREEN.HEIGHT; | |
let width = this._width; | |
if (this._isLineResized) { | |
width = ver === 'html5' ? Math.floor(width * 0.5 - 8) : (width * 0.5 + 4 * 0.5); | |
} | |
let isOverflowWidth = width > screenWidth; | |
if (isOverflowWidth) { | |
if (this._isLineResized) { | |
screenWidth *= 2; | |
this._isDoubleResized = true; | |
} | |
this._setScale(screenWidth / width); | |
} else { | |
this._setScale(1); | |
} | |
if (this._type === NicoChat.TYPE.BOTTOM) { | |
this._y = screenHeight - this._height; | |
} | |
} | |
_setupMarqueeMode () { | |
if (this._isLineResized) { | |
let duration = this._duration / this._speedRate; | |
this._setScale(this._scale); | |
let speed = | |
this._speed = (this._width + CommentLayer.SCREEN.WIDTH) / duration; | |
this._endLeftTiming = this._endRightTiming - this._width / speed; | |
this._beginRightTiming = this._beginLeftTiming + this._width / speed; | |
} | |
} | |
_setScale (scale) { | |
this._scale = scale; | |
let lsscale = scale * (this._isLineResized ? 0.5 : 1); | |
this._height = this._calculateHeight({isDoubleResized: this.isDoubleResized}) * scale; | |
this._width = this._originalWidth * lsscale; | |
} | |
get bulkLayoutData () { | |
return { | |
id: this.id, | |
fork: this.fork, | |
type: this.type, | |
isOverflow: this._isOverflow, | |
isInvisible: this.isInvisible, | |
isFixed: this.isFixed, | |
ypos: this.ypos, | |
slot: this.slot, | |
height: this.height, | |
beginLeft: this.beginLeftTiming, | |
beginRight: this.beginRightTiming, | |
endLeft: this.endLeftTiming, | |
endRight: this.endRightTiming, | |
layerId: this.layerId | |
}; | |
} | |
set bulkLayoutData(data) { | |
this.isOverflow = data.isOverflow; | |
this._y = data.ypos; | |
this._isLayouted = true; | |
} | |
reset () {} | |
get lineCount() { | |
return (this._htmlText || '').split('<br>').length; | |
} | |
get id() {return this._nicoChat.id;} | |
get text() {return this._text;} | |
get htmlText() {return this._htmlText; } | |
set isLayouted(v) {this._isLayouted = v;} | |
get isInView() {return this.isInViewBySecond(this.currentTime);} | |
isInViewBySecond(sec) { | |
if (!this._isLayouted || sec + 1 /* margin */ < this._beginLeftTiming) { | |
return false; | |
} | |
if (sec > this._endRightTiming) { | |
return false; | |
} | |
if (this.isInvisible) { | |
return false; | |
} | |
return true; | |
} | |
get isOverflow() {return this._isOverflow;} | |
set isOverflow(v) {this._isOverflow = v;} | |
get isInvisible() {return this._nicoChat.isInvisible;} | |
get width() {return this._width;} | |
get height() {return this._height;} | |
get duration() {return this._duration / this._speedRate;} | |
get speed() {return this._speed;} | |
get inviewTiming() {return this._beginLeftTiming;} | |
get beginLeftTiming() {return this._beginLeftTiming;} | |
get beginRightTiming() {return this._beginRightTiming;} | |
get endLeftTiming() {return this._endLeftTiming; } | |
get endRightTiming() {return this._endRightTiming;} | |
get vpos() {return this._nicoChat.vpos;} | |
get xpos() {return this.getXposBySecond(this.currentTime);} | |
get ypos() {return this._y;} | |
set ypos(v) { this._y = v;} | |
get slot() {return this._slot;} | |
set slot(v) {this._slot = v;} | |
get color() {return this._nicoChat.color;} | |
get size() {return this._nicoChat.size;} | |
get type() {return this._nicoChat.type;} | |
get cssScale() {return this._scale * (this._isLineResized ? 0.5 : 1);} | |
get fontSizePixel() {return this._fontSizePixel;} | |
get lineHeight() {return this._cssLineHeight;} | |
get isLineResized() {return this._isLineResized;} | |
get isDoubleResized() {return this._isDoubleResized;} | |
get no() {return this._nicoChat.no;} | |
get uniqNo() {return this._nicoChat.uniqNo;} | |
get layerId() {return this._nicoChat.layerId;} | |
get fork() {return this._nicoChat.fork;} | |
get nicoru() { return this._nicoChat.nicoru; } | |
get nicotta() { return this._nicoChat.nicotta; } | |
getXposBySecond(sec) { | |
if (this._isFixed) { | |
return (CommentLayer.SCREEN.WIDTH - this._width) / 2; | |
} else { | |
let diff = sec - this._beginLeftTiming; | |
return CommentLayer.SCREEN.WIDTH + diff * this._speed; | |
} | |
} | |
getXposByVpos(vpos) { | |
return this.getXposBySecond(vpos / 100); | |
} | |
get currentTime() {return this._nicoChat.currentTime;} | |
get isFull() {return this._nicoChat.isFull;} | |
get isFixed() { return this._isFixed; } | |
get isNicoScript() {return this._nicoChat.isNicoScript;} | |
get isMine() {return this._nicoChat.isMine;} | |
get isUpdating() {return this._nicoChat.isUpdating;} | |
get isPostFail() {return this._nicoChat.isPostFail;} | |
get isReverse() {return this._nicoChat.isReverse;} | |
get isSubThread() {return this._nicoChat.isSubThread;} | |
get fontCommand() {return this._nicoChat.fontCommand;} | |
get commentVer() {return this._nicoChat.commentVer;} | |
get cssScaleY() {return this.cssScale * this._cssScaleY;} | |
get meta() { // debug用 | |
return JSON.stringify({ | |
width: this.width, | |
height: this.height, | |
scale: this.cssScale, | |
cmd: this._nicoChat.cmd, | |
fontSize: this.fontSizePixel, | |
vpos: this.vpos, | |
xpos: this.xpos, | |
ypos: this.ypos, | |
slot: this.slot, | |
type: this.type, | |
begin: this.beginLeftTiming, | |
end: this.endRightTiming, | |
speed: this.speed, | |
color: this.color, | |
size: this.size, | |
duration: this.duration, | |
opacity: this.opacity, | |
ender: this._nicoChat.isEnder, | |
full: this._nicoChat.isFull, | |
no: this._nicoChat.no, | |
uniqNo: this._nicoChat.uniqNo, | |
score: this._nicoChat.score, | |
userId: this._nicoChat.userId, | |
date: this._nicoChat.date, | |
fork: this._nicoChat.fork, | |
layerId: this._nicoChat.layerId, | |
ver: this._nicoChat.commentVer, | |
lc: this.lineCount, | |
ls: this.isLineResized, | |
thread: this._nicoChat.threadId, | |
isSub: this._nicoChat.isSubThread, | |
text: this.text | |
}); | |
} | |
checkCollision(target) { | |
if (this.isOverflow || target.isOverflow || target.isInvisible) { | |
return false; | |
} | |
if (this.layerId !== target.layerId) { | |
return false; | |
} | |
const targetY = target.ypos; | |
const selfY = this.ypos; | |
if (targetY + target.height < selfY || | |
targetY > selfY + this.height) { | |
return false; | |
} | |
let rt, lt; | |
if (this.beginLeftTiming <= target.beginLeftTiming) { | |
lt = this; | |
rt = target; | |
} else { | |
lt = target; | |
rt = this; | |
} | |
if (this.isFixed) { | |
if (lt.endRightTiming > rt.beginLeftTiming) { | |
return true; | |
} | |
} else { | |
if (lt.beginRightTiming >= rt.beginLeftTiming) { | |
return true; | |
} | |
if (lt.endRightTiming >= rt.endLeftTiming) { | |
return true; | |
} | |
} | |
return false; | |
} | |
moveToNextLine(others) { | |
let margin = 1; //NicoChatViewModel.CHAT_MARGIN; | |
let othersHeight = others.height + margin; | |
let overflowMargin = 10; | |
let rnd = Math.max(0, CommentLayer.SCREEN.HEIGHT - this.height); | |
let yMax = CommentLayer.SCREEN.HEIGHT - this.height + overflowMargin; | |
let yMin = 0 - overflowMargin; | |
let type = this.type; | |
let ypos = this.ypos; | |
if (type !== NicoChat.TYPE.BOTTOM) { | |
ypos += othersHeight; | |
if (ypos > yMax) { | |
this.isOverflow = true; | |
} | |
} else { | |
ypos -= othersHeight; | |
if (ypos < yMin) { | |
this.isOverflow = true; | |
} | |
} | |
this.ypos = this.isOverflow ? Math.floor(Math.random() * rnd) : ypos; | |
} | |
get time3d() {return this._nicoChat.time3d;} | |
get time3dp() {return this._nicoChat.time3dp;} | |
get opacity() {return this._nicoChat.opacity;} | |
} | |
NicoChatViewModel.emitter = new Emitter(); | |
NicoChatViewModel.FONT = '\'MS Pゴシック\''; //  | |
NicoChatViewModel.FONT_SIZE_PIXEL = { | |
BIG: 39, // 39 | |
MEDIUM: 24, | |
SMALL: 16 //15 | |
}; | |
NicoChatViewModel.FONT_SIZE_PIXEL_VER_HTML5 = { | |
BIG: 40 - 1, // 684 / 17 > x > 684 / 18 | |
MEDIUM: 27 -1, // 684 / 25 > x > 684 / 26 | |
SMALL: 18.4 -1 // 684 / 37 > x > 684 / 38 | |
}; | |
NicoChatViewModel.LINE_HEIGHT = { | |
BIG: 45, | |
MEDIUM: 29, | |
SMALL: 18 | |
}; | |
NicoChatViewModel.CHAT_MARGIN = 5; | |
NicoChatViewModel.BASE_SCALE = parseFloat(Config.props.baseChatScale, 10); | |
Config.onkey('baseChatScale', scale => { | |
if (isNaN(scale)) { | |
return; | |
} | |
scale = parseFloat(scale, 10); | |
NicoChatViewModel.BASE_SCALE = scale; | |
NicoChatViewModel.emitter.emit('updateBaseChatScale', scale); | |
}); | |
NicoChatViewModel.SPEED_RATE = 1.0; | |
class FlashNicoChatViewModel extends NicoChatViewModel {} | |
class HTML5NicoChatViewModel extends NicoChatViewModel { | |
_calculateHeight ({scale = 1, lc = 0, size, isEnder/*, isDoubleResized*/}) { | |
lc = lc || this.lineCount; | |
isEnder = typeof isEnder === 'boolean' ? isEnder : this._nicoChat.isEnder; | |
size = size || this._size; | |
const SIZE = NicoChat.SIZE; | |
const MARGIN = 4; | |
const SCREEN_HEIGHT = CommentLayer.SCREEN.HEIGHT; | |
const INNER_HEIGHT = SCREEN_HEIGHT - MARGIN; | |
const TABLE_HEIGHT = 360 - MARGIN; | |
const RATIO = INNER_HEIGHT / TABLE_HEIGHT; | |
scale *= RATIO; | |
this._isLineResized = false; | |
let lineHeight; | |
let height; | |
// @see https://ch.nicovideo.jp/883797/blomaga/ar1149544 | |
switch (size) { | |
case SIZE.BIG: | |
lineHeight = 47; | |
break; | |
case SIZE.SMALL: | |
lineHeight = 22; | |
break; | |
default: | |
lineHeight = 32; | |
break; | |
} | |
this._cssLineHeight = lineHeight; | |
if (lc === 1) { | |
return (lineHeight * scale - 1) * NicoChatViewModel.BASE_SCALE; | |
} | |
switch (size) { | |
case SIZE.BIG: | |
lineHeight = TABLE_HEIGHT / (8 * (TABLE_HEIGHT / 340)); | |
break; | |
case SIZE.SMALL: | |
lineHeight = TABLE_HEIGHT / (21 * (TABLE_HEIGHT / 354)); | |
break; | |
default: | |
lineHeight = TABLE_HEIGHT / (13 * (TABLE_HEIGHT / 357)); | |
break; | |
} | |
height = (lineHeight * lc + MARGIN) * scale * NicoChatViewModel.BASE_SCALE; | |
if (isEnder || height < TABLE_HEIGHT / 3) { | |
this._cssLineHeight = lineHeight; | |
return height - 1; | |
} | |
this._isLineResized = true; | |
switch (size) { | |
case SIZE.BIG: | |
lineHeight = TABLE_HEIGHT / 16; | |
break; | |
case SIZE.SMALL: | |
lineHeight = TABLE_HEIGHT / 38; | |
break; | |
default: | |
lineHeight = TABLE_HEIGHT / (25 * (TABLE_HEIGHT / 351)); | |
} | |
this._cssLineHeight = lineHeight * 2; | |
return ((lineHeight * lc + MARGIN) * scale - 1) * NicoChatViewModel.BASE_SCALE; | |
} | |
_setScale_ (scale) { | |
this._scale = scale; | |
this._height = this._calculateHeight({}) * scale; | |
this._width = this._originalWidth * scale * (this._isLineResized ? 0.5 : 1); | |
} | |
getCssScaleY() { | |
return this.cssScale; | |
} | |
} | |
class NicoChatCss3View { | |
static buildChatDom (chat, type, size, cssText, document = window.document) { | |
const span = document.createElement('span'); | |
const ver = chat.commentVer; | |
const className = ['nicoChat', 'hidden', type, size]; | |
if (ver === 'html5') { | |
className.push(ver); | |
} | |
if (chat.color === '#000000') { | |
className.push('black'); | |
} | |
if (chat.isDoubleResized) { | |
className.push('is-doubleResized'); | |
} else if (chat.isLineResized) { | |
className.push('is-lineResized'); | |
} | |
if (chat.isOverflow) { | |
className.push('overflow'); | |
} | |
if (chat.isMine) { | |
className.push('mine'); | |
} | |
if (chat.isUpdating) { | |
className.push('updating'); | |
} | |
if (chat.nicotta) { | |
className.push('nicotta'); | |
} | |
let fork = chat.fork; | |
className.push(`fork${fork}`); | |
if (chat.isPostFail) { | |
className.push('fail'); | |
} | |
const fontCommand = chat.fontCommand; | |
if (fontCommand) { | |
className.push(`cmd-${fontCommand}`); | |
} | |
span.className = className.join(' '); | |
span.id = chat.id; | |
span.dataset.meta = chat.meta; | |
if (!chat.isInvisible) { | |
const {inline, keyframes} = cssText || {}; | |
if (inline) { | |
span.style.cssText = inline; | |
} | |
span.innerHTML = chat.htmlText; | |
if (keyframes) { | |
const style = document.createElement('style'); | |
style.append(keyframes); | |
span.append(style); | |
} | |
} | |
return span; | |
} | |
static buildStyleElement (cssText, document = window.document) { | |
const elm = document.createElement('style'); | |
elm.type = 'text/css'; | |
elm.append(cssText); | |
return elm; | |
} | |
static buildChatHtml (chat, type, cssText, document = window.document) { | |
const result = NicoChatCss3View.buildChatDom(chat, type, chat.size, cssText, document); | |
result.removeAttribute('data-meta'); | |
return result.outerHTML; | |
} | |
static buildChatCss (chat, type, currentTime = 0, playbackRate = 1) { | |
return type === NicoChat.TYPE.NAKA ? | |
NicoChatCss3View._buildNakaCss(chat, type, currentTime, playbackRate) : | |
NicoChatCss3View._buildFixedCss(chat, type, currentTime, playbackRate); | |
} | |
static _buildNakaCss(chat, type, currentTime, playbackRate) { | |
let id = chat.id; | |
let commentVer = chat.commentVer; | |
let duration = chat.duration / playbackRate; | |
let scale = chat.cssScale; | |
let scaleY = chat.cssScaleY; | |
let beginL = chat.beginLeftTiming; | |
let screenWidth = CommentLayer.SCREEN.WIDTH; | |
let screenHeight = CommentLayer.SCREEN.HEIGHT; | |
let height = chat.height; | |
let ypos = chat.ypos; | |
let isSub = chat.isSubThread; | |
let color = chat.color; | |
let colorCss = color ? `color: ${color};` : ''; | |
let fontSizePx = chat.fontSizePixel; | |
let lineHeightCss = ''; | |
if (commentVer !== 'html5') { | |
lineHeightCss = `line-height: ${Math.floor(chat.lineHeight)}px;`; | |
} | |
let speed = chat.speed; | |
let delay = (beginL - currentTime) / playbackRate; | |
let slot = chat.slot; | |
let zIndex = | |
(slot >= 0) ? | |
(slot * 1000 + chat.fork * 1000000 + 1) : | |
(1000 + beginL * 1000 + chat.fork * 1000000); | |
zIndex = isSub ? zIndex: zIndex * 2; | |
const opacity = chat.opacity !== 1 ? `opacity: ${chat.opacity};` : ''; | |
const outerScreenWidth = CommentLayer.SCREEN.OUTER_WIDTH_FULL; | |
const screenDiff = outerScreenWidth - screenWidth; | |
const leftPos = screenWidth + screenDiff / 2; | |
const durationDiff = screenDiff / speed / playbackRate; | |
duration += durationDiff; | |
delay -= (durationDiff * 0.5); | |
const reverse = chat.isReverse ? 'animation-direction: reverse;' : ''; | |
let isAlignMiddle = false; | |
if ((commentVer === 'html5' && (height >= screenHeight - fontSizePx / 2 || chat.isOverflow)) || | |
(commentVer !== 'html5' && height >= screenHeight - fontSizePx / 2 && height < screenHeight + fontSizePx) | |
) { | |
isAlignMiddle = true; | |
} | |
const top = isAlignMiddle ? '50%' : `${ypos}px`; | |
const isScaled = scale !== 1.0 || scaleY !== 1.0; | |
const inline = ` | |
--chat-trans-x: -${outerScreenWidth + chat.width * scale}px; | |
${isAlignMiddle ? '--chat-trans-y: -50%' : ''}; | |
${isScaled ? | |
`--chat-scale-x: ${scale};--chat-scale-y: ${scaleY};` : ''} | |
display: inline-block; | |
position: absolute; | |
will-change: transform; | |
contain: layout style paint; | |
visibility: hidden; | |
line-height: 1.235; | |
z-index: ${zIndex}; | |
top: ${top}; | |
left: ${leftPos}px; | |
${colorCss} | |
${lineHeightCss} | |
${opacity} | |
font-size: ${fontSizePx}px; | |
animation-name: idou-props${(isScaled || isAlignMiddle) ? '-scaled' : ''}${isAlignMiddle ? '-middle' : ''}; | |
animation-duration: ${duration}s; | |
animation-delay: ${delay}s; | |
${reverse} | |
transform: | |
translateX(0) | |
; | |
content-visibility: hidden; | |
`; | |
return {inline, keyframes: ''}; | |
} | |
static _buildFixedCss(chat, type, currentTime, playbackRate) { | |
let scaleCss; | |
let commentVer = chat.commentVer; | |
let duration = chat.duration / playbackRate; | |
let scale = chat.cssScale;// * (chat.isLineResized ? 0.5 : 1); | |
let scaleY = chat.cssScaleY; | |
let beginL = chat.beginLeftTiming; | |
let screenHeight = CommentLayer.SCREEN.HEIGHT; | |
let height = chat.height; | |
let ypos = chat.ypos; | |
let isSub = chat.isSubThread; | |
let color = chat.color; | |
let colorCss = color ? `color: ${color};` : ''; | |
let fontSizePx = chat.fontSizePixel; | |
let lineHeightCss = ''; | |
if (commentVer !== 'html5') { | |
lineHeightCss = `line-height: ${Math.floor(chat.lineHeight)}px;`; | |
} | |
let delay = (beginL - currentTime) / playbackRate; | |
let slot = chat.slot; | |
let zIndex = | |
(slot >= 0) ? | |
(slot * 1000 + chat.fork * 1000000 + 1) : | |
(1000 + beginL * 1000 + chat.fork * 1000000); | |
zIndex = isSub ? zIndex: zIndex * 2; | |
let time3d = '0';//`${delay * 10}px`; //${chat.time3dp * 100}px`; | |
let opacity = chat.opacity !== 1 ? `opacity: ${chat.opacity};` : ''; | |
let top; | |
let transY; | |
if ((commentVer === 'html5' && height >= screenHeight - fontSizePx / 2 /*|| chat.isOverflow*/) || | |
(commentVer !== 'html5' && height >= screenHeight * 0.7)) { | |
top = `${type === NicoChat.TYPE.BOTTOM ? 100 : 0}%`; | |
transY = `${type === NicoChat.TYPE.BOTTOM ? -100 : 0}%`; | |
} else { | |
top = ypos + 'px'; | |
transY = '0'; | |
} | |
scaleCss = | |
scale === 1.0 ? | |
`transform: scale3d(1, ${scaleY}, 1) translate3d(-50%, ${transY}, ${time3d});` : | |
`transform: scale3d(${scale}, ${scaleY}, 1) translate3d(-50%, ${transY}, ${time3d});`; | |
const inline = ` | |
z-index: ${zIndex}; | |
top: ${top}; | |
left: 50%; | |
${colorCss} | |
${lineHeightCss} | |
${opacity} | |
font-size: ${fontSizePx}px; | |
${scaleCss} | |
animation-duration: ${duration / 0.95}s; | |
animation-delay: ${delay}s; | |
--dokaben-scale: ${scale}; | |
content-visibility: hidden; | |
`.trim(); | |
return {inline}; | |
} | |
} | |
class NicoChatFilter extends Emitter { | |
constructor(params) { | |
super(); | |
this._sharedNgLevel = params.sharedNgLevel || NicoChatFilter.SHARED_NG_LEVEL.MID; | |
this._removeNgMatchedUser = params.removeNgMatchedUser || false; | |
this._wordFilterList = []; | |
this._userIdFilterList = []; | |
this._commandFilterList = []; | |
this.wordFilterList = params.wordFilter || ''; | |
this.userIdFilterList = params.userIdFilter || ''; | |
this.commandFilterList = params.commandFilter || ''; | |
this._fork0 = typeof params.fork0 === 'boolean' ? params.fork0 : true; | |
this._fork1 = typeof params.fork1 === 'boolean' ? params.fork1 : true; | |
this._fork2 = typeof params.fork2 === 'boolean' ? params.fork2 : true; | |
this._enable = typeof params.enableFilter === 'boolean' ? params.enableFilter : true; | |
this._wordReg = null; | |
this._wordRegReg = null; | |
this._userIdReg = null; | |
this._commandReg = null; | |
this._onChange = _.debounce(this._onChange.bind(this), 50); | |
if (params.wordRegFilter) { | |
this.setWordRegFilter(params.wordRegFilter, params.wordRegFilterFlags); | |
} | |
} | |
get isEnable() { | |
return this._enable; | |
} | |
set isEnable(v) { | |
if (this._enable === v) { | |
return; | |
} | |
this._enable = !!v; | |
this._onChange(); | |
} | |
get removeNgMatchedUser() { | |
return this._removeNgMatchedUser; | |
} | |
set removeNgMatchedUser(v) { | |
if (this._removeNgMatchedUser === v) { | |
return; | |
} | |
this._removeNgMatchedUser = !!v; | |
this.refresh(); | |
} | |
get fork0() { return this._fork0; } | |
set fork0(v) { | |
v = !!v; | |
if (this._fork0 === v) { return; } | |
this._fork0 = v; | |
this.refresh(); | |
} | |
get fork1() { return this._fork1; } | |
set fork1(v) { | |
v = !!v; | |
if (this._fork1 === v) { return; } | |
this._fork1 = v; | |
this.refresh(); | |
} | |
get fork2() { return this._fork2; } | |
set fork2(v) { | |
v = !!v; | |
if (this._fork2 === v) { return; } | |
this._fork2 = v; | |
this.refresh(); | |
} | |
refresh() { this._onChange(); } | |
addWordFilter(text) { | |
let before = this._wordFilterList.join('\n'); | |
this._wordFilterList.push((text || '').trim()); | |
this._wordFilterList = [...new Set(this._wordFilterList)]; | |
let after = this._wordFilterList.join('\n'); | |
if (before === after) { return; } | |
this._wordReg = null; | |
this._onChange(); | |
} | |
set wordFilterList(list) { | |
list = [...new Set(typeof list === 'string' ? list.trim().split('\n') : list)]; | |
let before = this._wordFilterList.join('\n'); | |
let tmp = []; | |
list.forEach(text => { | |
if (!text) { return; } | |
tmp.push(text.trim()); | |
}); | |
tmp = _.compact(tmp); | |
let after = tmp.join('\n'); | |
if (before === after) { return; } | |
this._wordReg = null; | |
this._wordFilterList = tmp; | |
this._onChange(); | |
} | |
get wordFilterList() { | |
return this._wordFilterList; | |
} | |
setWordRegFilter(source, flags) { | |
if (this._wordRegReg) { | |
if (this._wordRegReg.source === source && this._flags === flags) { | |
return; | |
} | |
} | |
try { | |
this._wordRegReg = new RegExp(source, flags); | |
} catch (e) { | |
window.console.error(e); | |
return; | |
} | |
this._onChange(); | |
} | |
addUserIdFilter(text) { | |
const before = this._userIdFilterList.join('\n'); | |
this._userIdFilterList.push(text); | |
this._userIdFilterList = [...new Set(this._userIdFilterList)]; | |
const after = this._userIdFilterList.join('\n'); | |
if (before === after) { return; } | |
this._userIdReg = null; | |
this._onChange(); | |
} | |
set userIdFilterList(list) { | |
list = [...new Set(typeof list === 'string' ? list.trim().split('\n') : list)]; | |
let before = this._userIdFilterList.join('\n'); | |
let tmp = []; | |
list.forEach(text => { | |
if (!text) { return; } | |
tmp.push(text.trim()); | |
}); | |
tmp = _.compact(tmp); | |
let after = tmp.join('\n'); | |
if (before === after) { return; } | |
this._userIdReg = null; | |
this._userIdFilterList = tmp; | |
this._onChange(); | |
} | |
get userIdFilterList() { | |
return this._userIdFilterList; | |
} | |
addCommandFilter(text) { | |
let before = this._commandFilterList.join('\n'); | |
this._commandFilterList.push(text); | |
this._commandFilterList = [...new Set(this._commandFilterList)]; | |
let after = this._commandFilterList.join('\n'); | |
if (before === after) { return; } | |
this._commandReg = null; | |
this._onChange(); | |
} | |
set commandFilterList(list) { | |
list = [...new Set(typeof list === 'string' ? list.trim().split('\n') : list)]; | |
let before = this._commandFilterList.join('\n'); | |
let tmp = []; | |
list.forEach(text => { | |
if (!text) { return; } | |
tmp.push(text.trim()); | |
}); | |
tmp = _.compact(tmp); | |
let after = tmp.join('\n'); | |
if (before === after) { return; } | |
this._commandReg = null; | |
this._commandFilterList = tmp; | |
this._onChange(); | |
} | |
get commandFilterList() { | |
return this._commandFilterList; | |
} | |
set sharedNgLevel(level) { | |
if (NicoChatFilter.SHARED_NG_LEVEL[level] && this._sharedNgLevel !== level) { | |
this._sharedNgLevel = level; | |
this._onChange(); | |
} | |
} | |
get sharedNgLevel() { | |
return this._sharedNgLevel; | |
} | |
getFilterFunc() { | |
if (!this._enable) { | |
return () => true; | |
} | |
const threthold = NicoChatFilter.SHARED_NG_SCORE[this._sharedNgLevel]; | |
if (!this._wordReg) { | |
this._wordReg = this._buildFilterReg(this._wordFilterList); | |
} | |
const umatch = this._userIdFilterList.length ? this._userIdFilterList : null; | |
if (!this._commandReg) { | |
this._commandReg = this._buildFilterReg(this._commandFilterList); | |
} | |
const wordReg = this._wordReg; | |
const wordRegReg = this._wordRegReg; | |
const commandReg = this._commandReg; | |
if (Config.getValue('debug')) { | |
return nicoChat => { | |
if (nicoChat.fork === 1) { | |
return true; | |
} | |
const score = nicoChat.score; | |
if (score <= threthold) { | |
window.console.log('%cNG共有適用: %s <= %s %s %s秒 %s', 'background: yellow;', | |
score, | |
threthold, | |
nicoChat.type, | |
nicoChat.vpos / 100, | |
nicoChat.text | |
); | |
return false; | |
} | |
let m; | |
wordReg && (m = wordReg.exec(nicoChat.text)); | |
if (m) { | |
window.console.log('%cNGワード: "%s" %s %s秒 %s', 'background: yellow;', | |
m[1], | |
nicoChat.type, | |
nicoChat.vpos / 100, | |
nicoChat.text | |
); | |
return false; | |
} | |
wordRegReg && (m = wordRegReg.exec(nicoChat.text)); | |
if (m) { | |
window.console.log( | |
'%cNGワード(正規表現): "%s" %s %s秒 %s', | |
'background: yellow;', | |
m[1], | |
nicoChat.type, | |
nicoChat.vpos / 100, | |
nicoChat.text | |
); | |
return false; | |
} | |
if (umatch && umatch.includes(nicoChat.userId)) { | |
window.console.log('%cNGID: "%s" %s %s秒 %s %s', 'background: yellow;', | |
nicoChat.userId, | |
nicoChat.type, | |
nicoChat.vpos / 100, | |
nicoChat.userId, | |
nicoChat.text | |
); | |
return false; | |
} | |
commandReg && (m = commandReg.test(nicoChat.cmd)); | |
if (m) { | |
window.console.log('%cNG command: "%s" %s %s秒 %s %s', 'background: yellow;', | |
m[1], | |
nicoChat.type, | |
nicoChat.vpos / 100, | |
nicoChat.cmd, | |
nicoChat.text | |
); | |
return false; | |
} | |
return true; | |
}; | |
} | |
return nicoChat => { | |
if (nicoChat.fork === 1) { //fork1 投稿者コメントはNGしない | |
return true; | |
} | |
const text = nicoChat.text; | |
return !( | |
(nicoChat.score <= threthold) || | |
(wordReg && wordReg.test(text)) || | |
(wordRegReg && wordRegReg.test(text)) || | |
(umatch && umatch.includes(nicoChat.userId)) || | |
(commandReg && commandReg.test(nicoChat.cmd)) | |
); | |
}; | |
} | |
applyFilter(nicoChatArray) { | |
let before = nicoChatArray.length; | |
if (before < 1) { | |
return nicoChatArray; | |
} | |
let timeKey = 'applyNgFilter: ' + nicoChatArray[0].type; | |
window.console.time(timeKey); | |
let filterFunc = this.getFilterFunc(); | |
let result = nicoChatArray.filter(filterFunc); | |
if (before.length !== result.length && this._removeNgMatchedUser) { | |
let removedUserIds = | |
nicoChatArray.filter(chat => !result.includes(chat)).map(chat => chat.userId); | |
result = result.filter(chat => !removedUserIds.includes(chat.userId)); | |
} | |
if (!this.fork0 || !this.fork1 || !this.fork2) { | |
const allows = []; | |
this._fork0 && allows.push(0); | |
this._fork1 && allows.push(1); | |
this._fork2 && allows.push(2); | |
result = result.filter(chat => allows.includes(chat.fork)); | |
} | |
window.console.timeEnd(timeKey); | |
window.console.log('NG判定結果: %s/%s', result.length, before); | |
return result; | |
} | |
isSafe(nicoChat) { | |
return (this.getFilterFunc())(nicoChat); | |
} | |
_buildFilterReg(filterList) { | |
if (filterList.length < 1) { | |
return null; | |
} | |
const escapeRegs = textUtil.escapeRegs; | |
let r = filterList.filter(f => f).map(f => escapeRegs(f)); | |
return new RegExp('(' + r.join('|') + ')', 'i'); | |
} | |
_buildFilterPerfectMatchinghReg(filterList) { | |
if (filterList.length < 1) { | |
return null; | |
} | |
const escapeRegs = textUtil.escapeRegs; | |
let r = filterList.filter(f => f).map(f => escapeRegs(f)); | |
return new RegExp('^(' + r.join('|') + ')$'); | |
} | |
_onChange() { | |
console.log('NicoChatFilter.onChange'); | |
this.emit('change'); | |
} | |
} | |
NicoChatFilter.SHARED_NG_LEVEL = { | |
NONE: 'NONE', | |
LOW: 'LOW', | |
MID: 'MID', | |
HIGH: 'HIGH', | |
MAX: 'MAX' | |
}; | |
NicoChatFilter.SHARED_NG_SCORE = { | |
NONE: -99999,//Number.MIN_VALUE, | |
LOW: -10000, | |
MID: -5000, | |
HIGH: -1000, | |
MAX: -1 | |
}; | |
class NicoCommentPlayer extends Emitter { | |
constructor(params) { | |
super(); | |
this._model = new NicoComment(params); | |
this._viewModel = new NicoCommentViewModel(this._model); | |
this._view = new NicoCommentCss3PlayerView({ | |
viewModel: this._viewModel, | |
playbackRate: params.playbackRate, | |
show: params.showComment, | |
opacity: _.isNumber(params.commentOpacity) ? params.commentOpacity : 1.0 | |
}); | |
const onCommentChange = _.throttle(this._onCommentChange.bind(this), 1000); | |
this._model.on('change', onCommentChange); | |
this._model.on('filterChange', this._onFilterChange.bind(this)); | |
this._model.on('parsed', this._onCommentParsed.bind(this)); | |
this._model.on('command', this._onCommand.bind(this)); | |
global.emitter.on('commentLayoutChange', onCommentChange); | |
global.debug.nicoCommentPlayer = this; | |
this.emitResolve('GetReady!'); | |
} | |
setComment(data, options) { | |
if (typeof data === 'string') { | |
if (options.format === 'json') { | |
this._model.setData(JSON.parse(data), options); | |
} else { | |
this._model.setXml(new DOMParser().parseFromString(data, 'text/xml'), options); | |
} | |
} else if (typeof data.getElementsByTagName === 'function') { | |
this._model.setXml(data, options); | |
} else { | |
this._model.setData(data, options); | |
} | |
} | |
_onCommand(command, param) { | |
this.emit('command', command, param); | |
} | |
_onCommentChange(e) { | |
console.log('onCommentChange', e); | |
if (this._view) { | |
setTimeout(() => this._view.refresh(), 0); | |
} | |
this.emit('change'); | |
} | |
_onFilterChange(nicoChatFilter) { | |
this.emit('filterChange', nicoChatFilter); | |
} | |
_onCommentParsed() { | |
this.emit('parsed'); | |
} | |
getMymemory() { | |
if (!this._view) { | |
this._view = new NicoCommentCss3PlayerView({ | |
viewModel: this._viewModel | |
}); | |
} | |
return this._view.export(); | |
} | |
set currentTime(sec) {this._model.currentTime=sec;} | |
get currentTime() {return this._model.currentTime;} | |
set vpos(vpos) {this._model.currentTime=vpos / 100;} | |
get vpos() {return this._model.currentTime * 100;} | |
setVisibility(v) { | |
if (v) { | |
this._view.show(); | |
} else { | |
this._view.hide(); | |
} | |
} | |
addChat(text, cmd, vpos, options) { | |
if (typeof vpos !== 'number') { | |
vpos = this.vpos; | |
} | |
const nicoChat = NicoChat.create(Object.assign({text, cmd, vpos}, options)); | |
this._model.addChat(nicoChat); | |
return nicoChat; | |
} | |
set playbackRate(v) { | |
if (this._view) { | |
this._view.playbackRate = v; | |
} | |
} | |
get playbackRate() { | |
if (this._view) { return this._view.playbackRate; } | |
return 1; | |
} | |
setAspectRatio(ratio) { | |
this._view.setAspectRatio(ratio); | |
} | |
appendTo(node) { | |
this._view.appendTo(node); | |
} | |
show() { | |
this._view.show(); | |
} | |
hide() { | |
this._view.hide(); | |
} | |
close() { | |
this._model.clear(); | |
if (this._view) { | |
this._view.clear(); | |
} | |
} | |
get filter() {return this._model.filter;} | |
get chatList() {return this._model.chatList;} | |
get nonfilteredChatList() {return this._model.nonfilteredChatList;} | |
export() { | |
return this._viewModel.export(); | |
} | |
getCurrentScreenHtml() { | |
return this._view.getCurrentScreenHtml(); | |
} | |
} | |
const {MAX_COMMENT} = CommentLayer; | |
class NicoComment extends Emitter { | |
static getMaxCommentsByDuration(duration = 6 * 60 * 60 * 1000) { | |
if (duration < 64) { return 100; } | |
if (duration < 300) { return 250; } | |
if (duration < 600) { return 500; } | |
return 1000; | |
} | |
constructor(params) { | |
super(); | |
this._currentTime = 0; | |
params.nicoChatFilter = this._nicoChatFilter = new NicoChatFilter(params.filter || {}); | |
this._nicoChatFilter.on('change', this._onFilterChange.bind(this)); | |
NicoComment.offscreenLayer.get().then(async offscreen => { | |
params.offScreen = offscreen; | |
this.topGroup = new NicoChatGroup(NicoChat.TYPE.TOP, params); | |
this.nakaGroup = new NicoChatGroup(NicoChat.TYPE.NAKA, params); | |
this.bottomGroup = new NicoChatGroup(NicoChat.TYPE.BOTTOM, params); | |
this.nicoScripter = new NicoScripter(); | |
this.nicoScripter.on('command', (command, param) => this.emit('command', command, param)); | |
const onChange = _.debounce(this._onChange.bind(this), 100); | |
this.topGroup.on('change', onChange); | |
this.nakaGroup.on('change', onChange); | |
this.bottomGroup.on('change', onChange); | |
global.emitter.on('updateOptionCss', onChange); | |
await sleep.idle(); | |
this.emitResolve('GetReady!'); | |
}); | |
} | |
setXml(xml, options) { | |
const chatsData = Array.from(xml.getElementsByTagName('chat')).filter(chat => chat.firstChild); | |
return this.setChats(chatsData, options); | |
} | |
async setData(data, options) { | |
await this.promise('GetReady!'); | |
const chatsData = data.filter(d => d.chat).map(d => | |
Object.assign({text: d.chat.content || '', cmd: d.chat.mail || ''}, d.chat)); | |
return this.setChats(chatsData, options); | |
} | |
async setChats(chatsData, options = {}) { | |
this._options = options; | |
window.console.time('コメントのパース処理'); | |
const nicoScripter = this.nicoScripter; | |
if (!options.append) { | |
this.topGroup.reset(); | |
this.nakaGroup.reset(); | |
this.bottomGroup.reset(); | |
nicoScripter.reset(); | |
} | |
const videoDuration = this._duration = parseInt(options.duration || 0x7FFFFF); | |
const maxCommentsByDuration = this.constructor.getMaxCommentsByDuration(videoDuration); | |
const mainThreadId = options.mainThreadId || 0; | |
let nicoChats = []; | |
const top = [], bottom = [], naka = []; | |
const create = options.format !== 'xml' ? NicoChat.create : NicoChat.createFromChatElement; | |
for (let i = 0, len = Math.min(chatsData.length, MAX_COMMENT); i < len; i++) { | |
const chat = chatsData[i]; | |
const nicoChat = create(chat, {videoDuration, mainThreadId}); | |
if (nicoChat.isDeleted) { | |
continue; | |
} | |
if (nicoChat.isNicoScript) { | |
nicoScripter.add(nicoChat); | |
} | |
nicoChats.push(nicoChat); | |
} | |
nicoChats = [] | |
.concat(... // fork0 通常のコメント fork1 投稿者コメント fork2 かんたんコメント | |
nicoChats.filter(c => (c.isPatissier || c.isCA) && c.fork !== 1 && c.isSubThread) | |
.splice(maxCommentsByDuration)) | |
.concat(... | |
nicoChats.filter(c => (c.isPatissier || c.isCA) && c.fork !== 1 && !c.isSubThread) | |
.splice(maxCommentsByDuration)) | |
.concat(...nicoChats.filter(c => !(c.isPatissier || c.isCA) || c.fork === 1)); | |
window.console.timeLog && window.console.timeLog('コメントのパース処理', 'NicoChat created'); | |
nicoChats.filter(chat => chat.fork === 2).forEach(chat => chat.size = NicoChat.SIZE.SMALL); | |
if (_.isObject(options.replacement) && _.size(options.replacement) > 0) { | |
window.console.time('コメント置換フィルタ適用'); | |
this._wordReplacer = this.buildWordReplacer(options.replacement); | |
this._preProcessWordReplacement(nicoChats, this._wordReplacer); | |
window.console.timeEnd('コメント置換フィルタ適用'); | |
} else { | |
this._wordReplacer = null; | |
} | |
if (options.append) { | |
nicoChats = nicoChats.filter(chat => { | |
return !this.topGroup.includes(chat) && !this.nakaGroup.includes(chat) && !this.bottomGroup.includes(chat); | |
}); | |
} | |
let minTime = Date.now(); | |
let maxTime = 0; | |
for (const c of nicoChats) { | |
minTime = Math.min(minTime, c.date); | |
maxTime = Math.max(maxTime, c.date); | |
} | |
const timeDepth = maxTime - minTime; | |
for (const c of nicoChats) { | |
c.time3d = c.date - minTime; | |
c.time3dp = c.time3d / timeDepth; | |
} | |
if (!nicoScripter.isEmpty) { | |
window.console.time('ニコスクリプト適用'); | |
nicoScripter.apply(nicoChats); | |
window.console.timeEnd('ニコスクリプト適用'); | |
const nextVideo = nicoScripter.getNextVideo(); | |
window.console.info('nextVideo', nextVideo); | |
if (nextVideo) { | |
this.emitAsync('command', 'nextVideo', nextVideo); | |
} | |
} | |
const TYPE = NicoChat.TYPE; | |
for (const nicoChat of nicoChats) { | |
switch(nicoChat.type) { | |
case TYPE.TOP: | |
top.push(nicoChat); | |
break; | |
case TYPE.BOTTOM: | |
bottom.push(nicoChat); | |
break; | |
default: | |
naka.push(nicoChat); | |
break; | |
} | |
} | |
this.topGroup.addChatArray(top); | |
this.nakaGroup.addChatArray(naka); | |
this.bottomGroup.addChatArray(bottom); | |
window.console.timeEnd('コメントのパース処理'); | |
console.log('chats: ', chatsData.length); | |
console.log('top: ', this.topGroup.nonFilteredMembers.length); | |
console.log('naka: ', this.nakaGroup.nonFilteredMembers.length); | |
console.log('bottom: ', this.bottomGroup.nonFilteredMembers.length); | |
this.emit('parsed'); | |
} | |
buildWordReplacer(replacement) { | |
let func = text => text; | |
const makeFullReplacement = (f, src, dest) => { | |
return text => f(text.indexOf(src) >= 0 ? dest : text); | |
}; | |
const makeRegReplacement = (f, src, dest) => { | |
const reg = new RegExp(textUtil.escapeRegs(src), 'g'); | |
return text => f(text.replace(reg, dest)); | |
}; | |
for (const key of Object.keys(replacement)) { | |
if (!key) { | |
continue; | |
} | |
const val = replacement[key]; | |
window.console.log('コメント置換フィルタ: "%s" => "%s"', key, val); | |
if (key.charAt(0) === '*') { | |
func = makeFullReplacement(func, key.substr(1), val); | |
} else { | |
func = makeRegReplacement(func, key, val); | |
} | |
} | |
return func; | |
} | |
_preProcessWordReplacement(group, replacementFunc) { | |
for (const nicoChat of group) { | |
const text = nicoChat.text; | |
const newText = replacementFunc(text); | |
if (text !== newText) { | |
nicoChat.text = newText; | |
} | |
} | |
} | |
get chatList() { | |
return { | |
top: this.topGroup.members, | |
naka: this.nakaGroup.members, | |
bottom: this.bottomGroup.members | |
}; | |
} | |
get nonFilteredChatList() { | |
return { | |
top: this.topGroup.nonFilteredMembers, | |
naka: this.nakaGroup.nonFilteredMembers, | |
bottom: this.bottomGroup.nonFilteredMembers | |
}; | |
} | |
addChat(nicoChat) { | |
if (nicoChat.isDeleted) { | |
return; | |
} | |
const type = nicoChat.type; | |
if (this._wordReplacer) { | |
nicoChat.text = this._wordReplacer(nicoChat.text); | |
} | |
if (!this.nicoScripter.isEmpty) { | |
window.console.time('ニコスクリプト適用'); | |
this.nicoScripter.apply([nicoChat]); | |
window.console.timeEnd('ニコスクリプト適用'); | |
} | |
let group; | |
switch (type) { | |
case NicoChat.TYPE.TOP: | |
group = this.topGroup; | |
break; | |
case NicoChat.TYPE.BOTTOM: | |
group = this.bottomGroup; | |
break; | |
default: | |
group = this.nakaGroup; | |
break; | |
} | |
group.addChat(nicoChat, group); | |
this.emit('addChat'); | |
} | |
_onChange(e) { | |
console.log('NicoComment.onChange: ', e); | |
e = e || {}; | |
const ev = { | |
nicoComment: this, | |
group: e.group, | |
chat: e.chat | |
}; | |
this.emit('change', ev); | |
} | |
_onFilterChange() { | |
this.emit('filterChange', this._nicoChatFilter); | |
} | |
clear() { | |
this._xml = ''; | |
this.topGroup.reset(); | |
this.nakaGroup.reset(); | |
this.bottomGroup.reset(); | |
this.emit('clear'); | |
} | |
get currentTime() { | |
return this._currentTime; | |
} | |
set currentTime(sec) { | |
this._currentTime = sec; | |
this.topGroup.currentTime = sec; | |
this.nakaGroup.currentTime = sec; | |
this.bottomGroup.currentTime = sec; | |
this.nicoScripter.currentTime = sec; | |
this.emit('currentTime', sec); | |
} | |
seek(time) { this.currentTime = time; } | |
set vpos(vpos) { this.currentTime = vpos / 100; } | |
getGroup(type) { | |
switch (type) { | |
case NicoChat.TYPE.TOP: | |
return this.topGroup; | |
case NicoChat.TYPE.BOTTOM: | |
return this.bottomGroup; | |
default: | |
return this.nakaGroup; | |
} | |
} | |
get filter() {return this._nicoChatFilter;} | |
} | |
const OffscreenLayer = config => { | |
const __offscreen_tpl__ = (` | |
<!DOCTYPE html> | |
<html lang="ja"> | |
<head> | |
<meta charset="utf-8"> | |
<title>CommentLayer</title> | |
<style type="text/css" id="layoutCss">%LAYOUT_CSS%</style> | |
<style type="text/css" id="optionCss">%OPTION_CSS%</style> | |
<style type="text/css"> | |
.nicoChat { visibility: hidden; } | |
</style> | |
<body> | |
<div id="offScreenLayer" | |
style=" | |
width: 4096px; | |
height: 384px; | |
overflow: visible; | |
background: #fff; | |
white-space: pre; | |
pointer-events: none; | |
user-select: none; | |
contain: strict; | |
"></div> | |
</body></html> | |
`).trim(); | |
const emt = new Emitter(); | |
let offScreenFrame; | |
let offScreenLayer; | |
let textField; | |
let optionStyle; | |
const initializeOptionCss = optionStyle => { | |
const update = () => { | |
const tmp = []; | |
let baseFont = config.props.baseFontFamily; | |
const inner = optionStyle.innerHTML; | |
if (baseFont) { | |
baseFont = baseFont.replace(/[;{}*/]/g, ''); | |
tmp.push( | |
[ | |
'.gothic {font-family: %BASEFONT%; }\n', | |
'han_group {font-family: %BASEFONT%, Arial; }' | |
].join('').replace(/%BASEFONT%/g, baseFont) | |
); | |
} | |
tmp.push(`.nicoChat { font-weight: ${config.props.baseFontBolder ? config.props.cssFontWeight : 'normal'} !important; }`); | |
const newCss = tmp.join('\n'); | |
if (inner !== newCss) { | |
optionStyle.innerHTML = newCss; | |
global.emitter.emit('updateOptionCss', newCss); | |
} | |
}; | |
update(); | |
config.onkey('baseFontFamily', update); | |
config.onkey('baseFontBolder', update); | |
}; | |
const initialize = () => { | |
if (offScreenFrame) { | |
return; | |
} | |
window.console.time('createOffscreenLayer'); | |
const frame = document.createElement('iframe'); | |
offScreenFrame = frame; | |
frame.loading = 'eager'; | |
frame.className = 'offScreenLayer'; | |
frame.setAttribute('sandbox', 'allow-same-origin'); | |
frame.style.position = 'fixed'; | |
frame.style.top = '200vw'; | |
frame.style.left = '200vh'; | |
(document.body || document.documentElement).append(frame); | |
let layer; | |
const onload = () => { | |
frame.onload = null; | |
if (util.isChrome()) { frame.removeAttribute('srcdoc'); } | |
console.log('%conOffScreenLayerLoad', 'background: lightgreen;'); | |
createTextField(); | |
let doc = offScreenFrame.contentWindow.document; | |
layer = doc.getElementById('offScreenLayer'); | |
optionStyle = doc.getElementById('optionCss'); | |
initializeOptionCss(optionStyle); | |
offScreenLayer = { | |
getTextField: () => textField, | |
appendChild: elm => { | |
layer.append(elm); | |
}, | |
removeChild: elm => { | |
layer.removeChild(elm); | |
}, | |
get optionCss() { return optionStyle.innerHTML;} | |
}; | |
window.console.timeEnd('createOffscreenLayer'); | |
emt.emitResolve('GetReady!', offScreenLayer); | |
}; | |
const html = __offscreen_tpl__ | |
.replace('%LAYOUT_CSS%', NicoTextParser.__css__) | |
.replace('%OPTION_CSS%', ''); | |
if (typeof frame.srcdoc === 'string') { | |
frame.onload = onload; | |
frame.srcdoc = html; | |
} else { | |
const fcd = frame.contentWindow.document; | |
fcd.open(); | |
fcd.write(html); | |
fcd.close(); | |
window.setTimeout(onload, 0); | |
} | |
}; | |
const getLayer = _config => { | |
config = _config || config; | |
initialize(); | |
return emt.promise('GetReady!'); | |
}; | |
const createTextField = () => { | |
const layer = offScreenFrame.contentWindow.document.getElementById('offScreenLayer'); | |
const span = document.createElement('span'); | |
span.className = 'nicoChat'; | |
let scale = config.props.baseChatScale; //NicoChatViewModel.BASE_SCALE; | |
config.onkey('baseChatScale', v => scale = v); | |
textField = { | |
setText: text => { | |
span.innerHTML = text; | |
}, | |
setType: (type, size, fontCommand, ver) => { | |
fontCommand = fontCommand ? `cmd-${fontCommand}` : ''; | |
span.className = `nicoChat ${type} ${size} ${fontCommand} ${ver}`; | |
}, | |
setFontSizePixel: | |
(span.attributeStyleMap ? | |
pixel => span.attributeStyleMap.set('font-size', CSS.px(pixel)) : | |
pixel => span.style.fontSize = `${pixel}px`), | |
getOriginalWidth: () => span.offsetWidth, | |
getWidth: () => span.offsetWidth * scale, | |
getOriginalHeight: () => span.offsetHeight, | |
getHeight: () => span.offsetHeight * scale | |
}; | |
layer.append(span); | |
return span; | |
}; | |
return { | |
get: getLayer, | |
get optionCss() { return optionStyle.innerHTML; } | |
}; | |
}; | |
NicoComment.offscreenLayer = OffscreenLayer(Config); | |
class NicoCommentViewModel extends Emitter { | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
async initialize(nicoComment) { | |
const offScreen = this._offScreen = await NicoComment.offscreenLayer.get(); | |
this._currentTime = 0; | |
this._lastUpdate = 0; | |
this._topGroup = | |
new NicoChatGroupViewModel(nicoComment.getGroup(NicoChat.TYPE.TOP), offScreen); | |
this._nakaGroup = | |
new NicoChatGroupViewModel(nicoComment.getGroup(NicoChat.TYPE.NAKA), offScreen); | |
this._bottomGroup = | |
new NicoChatGroupViewModel(nicoComment.getGroup(NicoChat.TYPE.BOTTOM), offScreen); | |
const config = Config.namespace('commentLayer'); | |
if (config.props.enableSlotLayoutEmulation) { | |
this._slotLayoutWorker = SlotLayoutWorker.create(); | |
this._updateSlotLayout = _.debounce(this._updateSlotLayout.bind(this), 100); | |
} | |
nicoComment.on('setData', this._onSetData.bind(this)); | |
nicoComment.on('clear', this._onClear.bind(this)); | |
nicoComment.on('change', this._onChange.bind(this)); | |
nicoComment.on('parsed', this._onCommentParsed.bind(this)); | |
nicoComment.on('currentTime', this._onCurrentTime.bind(this)); | |
} | |
_onSetData() { | |
this.emit('setData'); | |
} | |
_onClear() { | |
this._topGroup.reset(); | |
this._nakaGroup.reset(); | |
this._bottomGroup.reset(); | |
this._lastUpdate = Date.now(); | |
this.emit('clear'); | |
} | |
_onCurrentTime(sec) { | |
this._currentTime = sec; | |
this.emit('currentTime', this._currentTime); | |
} | |
_onChange(e) { | |
this._lastUpdate = Date.now(); | |
this._updateSlotLayout(); | |
console.log('NicoCommentViewModel.onChange: ', e); | |
} | |
_onCommentParsed() { | |
this._lastUpdate = Date.now(); | |
this._updateSlotLayout(); | |
} | |
async _updateSlotLayout() { | |
if (!this._slotLayoutWorker) { | |
return; | |
} | |
window.console.time('SlotLayoutWorker call'); | |
const result = await this._slotLayoutWorker.post({ | |
command: 'layout', | |
params: { | |
lastUpdate: this._lastUpdate, | |
top: this._topGroup.bulkSlotData, | |
naka: this._nakaGroup.bulkSlotData, | |
bottom: this._bottomGroup.bulkSlotData | |
} | |
}); | |
if (result.lastUpdate !== this._lastUpdate) { | |
return console.warn('slotLayoutWorker changed', this._lastUpdate, result.lastUpdate); | |
} | |
this._topGroup.bulkSlotData = result.top; | |
this._nakaGroup.bulkSlotData = result.naka; | |
this._bottomGroup.bulkSlotData = result.bottom; | |
window.console.timeEnd('SlotLayoutWorker call'); | |
} | |
get currentTime() {return this._currentTime;} | |
export() { | |
const result = []; | |
result.push(['<comment ', | |
'>' | |
].join('')); | |
result.push(this._nakaGroup.export()); | |
result.push(this._topGroup.export()); | |
result.push(this._bottomGroup.export()); | |
result.push('</comment>'); | |
return result.join('\n'); | |
} | |
getGroup(type) { | |
switch (type) { | |
case NicoChat.TYPE.TOP: | |
return this._topGroup; | |
case NicoChat.TYPE.BOTTOM: | |
return this._bottomGroup; | |
default: | |
return this._nakaGroup; | |
} | |
} | |
get bulkLayoutData() { | |
return { | |
top: this._topGroup.bulkLayoutData, | |
naka: this._nakaGroup.bulkLayoutData, | |
bottom: this._bottomGroup.bulkLayoutData | |
}; | |
} | |
set bulkLayoutData(data) { | |
this._topGroup.bulkLayoutData = data.top; | |
this._nakaGroup.bulkLayoutData = data.naka; | |
this._bottomGroup.bulkLayoutData = data.bottom; | |
} | |
} | |
class NicoChatGroup extends Emitter { | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
initialize(type, params) { | |
this._type = type; | |
this._nicoChatFilter = params.nicoChatFilter; | |
this._nicoChatFilter.on('change', this._onFilterChange.bind(this)); | |
this.reset(); | |
} | |
reset() { | |
this._members = []; | |
this._filteredMembers = []; | |
} | |
addChatArray(nicoChatArray) { | |
let members = this._members; | |
let newMembers = []; | |
for (const nicoChat of nicoChatArray) { | |
newMembers.push(nicoChat); | |
members.push(nicoChat); | |
nicoChat.group = this; | |
} | |
newMembers = this._nicoChatFilter.applyFilter(nicoChatArray); | |
if (newMembers.length > 0) { | |
this._filteredMembers = this._filteredMembers.concat(newMembers); | |
this.emit('addChatArray', newMembers); | |
} | |
} | |
addChat(nicoChat) { | |
this._members.push(nicoChat); | |
nicoChat.group = this; | |
if (this._nicoChatFilter.isSafe(nicoChat)) { | |
this._filteredMembers.push(nicoChat); | |
this.emit('addChat', nicoChat); | |
} | |
} | |
get type() {return this._type;} | |
get members() { | |
if (this._filteredMembers.length > 0) { | |
return this._filteredMembers; | |
} | |
return this._filteredMembers = this._nicoChatFilter.applyFilter(this._members); | |
} | |
get nonFilteredMembers() { return this._members; } | |
onChange(e) { | |
console.log('NicoChatGroup.onChange: ', e); | |
this._filteredMembers = []; | |
this.emit('change', { | |
chat: e, | |
group: this | |
}); | |
} | |
_onFilterChange() { | |
this._filteredMembers = []; | |
this.onChange(null); | |
} | |
get currentTime() {return this._currentTime;} | |
set currentTime(sec) { | |
this._currentTime = sec; | |
} | |
setSharedNgLevel(level) { | |
if (NicoChatFilter.SHARED_NG_LEVEL[level] && this._sharedNgLevel !== level) { | |
this._sharedNgLevel = level; | |
this.onChange(null); | |
} | |
} | |
includes(nicoChat) { | |
const uno = nicoChat.uniqNo; | |
return this._members.find(m => m.uniqNo === uno); | |
} | |
} | |
class NicoChatGroupViewModel { | |
constructor(...args) { | |
this.initialize(...args); | |
} | |
initialize(nicoChatGroup, offScreen) { | |
this._nicoChatGroup = nicoChatGroup; | |
this._offScreen = offScreen; | |
this._members = []; | |
this._lastUpdate = 0; | |
this._vSortedMembers = []; | |
this._initWorker(); | |
nicoChatGroup.on('addChat', this._onAddChat.bind(this)); | |
nicoChatGroup.on('addChatArray', this._onAddChatArray.bind(this)); | |
nicoChatGroup.on('reset', this._onReset.bind(this)); | |
nicoChatGroup.on('change', this._onChange.bind(this)); | |
NicoChatViewModel.emitter.on('updateBaseChatScale', this._onChange.bind(this)); | |
NicoChatViewModel.emitter.on('updateCommentSpeedRate', this._onCommentSpeedRateUpdate.bind(this)); | |
this.addChatArray(nicoChatGroup.members); | |
} | |
_initWorker() { | |
this._layoutWorker = CommentLayoutWorker.getInstance(); | |
} | |
_onAddChatArray(nicoChatArray) { | |
this.addChatArray(nicoChatArray); | |
} | |
_onAddChat(nicoChat) { | |
this.addChat(nicoChat); | |
} | |
_onReset() { | |
this.reset(); | |
} | |
_onChange(e) { | |
console.log('NicoChatGroupViewModel.onChange: ', e); | |
window.console.time('_onChange'); | |
this.reset(); | |
this.addChatArray(this._nicoChatGroup.members); | |
window.console.timeEnd('_onChange'); | |
} | |
async _execCommentLayoutWorker() { | |
if (this._members.length < 1) { | |
return; | |
} | |
const type = this._members[0].type; | |
const result = await this._layoutWorker.post({ | |
command: 'layout', | |
params: { | |
type, | |
members: this.bulkLayoutData, | |
lastUpdate: this._lastUpdate, | |
} | |
}); | |
if (result.lastUpdate !== this._lastUpdate) { | |
console.warn('group changed', this._lastUpdate, result.lastUpdate); | |
return; | |
} | |
this.bulkLayoutData = result.members; | |
} | |
async addChatArray(nicoChatArray) { | |
for (let i = 0, len = nicoChatArray.length; i < len; i++) { | |
const nicoChat = nicoChatArray[i]; | |
const nc = NicoChatViewModel.create(nicoChat, this._offScreen); | |
this._members.push(nc); | |
if (i % 100 === 99) { | |
await new Promise(r => setTimeout(r, 10)); | |
} | |
} | |
if (this._members.length < 1) { | |
return; | |
} | |
this._lastUpdate = Date.now(); | |
this._execCommentLayoutWorker(); | |
} | |
_onCommentSpeedRateUpdate() { | |
this.changeSpeed(NicoChatViewModel.SPEED_RATE); | |
} | |
changeSpeed(speedRate = 1) { | |
for (const member of this._members) { | |
member.recalcBeginEndTiming(speedRate); | |
} | |
this._execCommentLayoutWorker(); | |
} | |
_groupCollision() { | |
this._createVSortedMembers(); | |
let members = this._vSortedMembers; | |
for (let i = 0, len = members.length; i < len; i++) { | |
let o = members[i]; | |
this.checkCollision(o); | |
o.isLayouted = true; | |
} | |
} | |
addChat(nicoChat) { | |
let timeKey = 'addChat:' + nicoChat.text; | |
window.console.time(timeKey); | |
let nc = NicoChatViewModel.create(nicoChat, this._offScreen); | |
this._lastUpdate = Date.now(); | |
this.checkCollision(nc); | |
nc.isLayouted =true; | |
this._members.push(nc); | |
this._execCommentLayoutWorker(); | |
window.console.timeEnd(timeKey); | |
} | |
reset() { | |
let m = this._members; | |
for (let i = 0, len = m.length; i < len; i++) { | |
m[i].reset(); | |
} | |
this._members = []; | |
this._vSortedMembers = []; | |
this._lastUpdate = Date.now(); | |
} | |
get currentTime() {return this._nicoChatGroup.currentTime;} | |
get type() {return this._nicoChatGroup.type;} | |
checkCollision(target) { | |
if (target.isInvisible) { | |
return; | |
} | |
const m = this._vSortedMembers; | |
const beginLeft = target.beginLeftTiming; | |
for (let i = 0, len = m.length; i < len; i++) { | |
const o = m[i]; | |
if (o === target) { | |
return; | |
} | |
if (beginLeft > o.endRightTiming) { | |
continue; | |
} | |
if (o.checkCollision(target)) { | |
target.moveToNextLine(o); | |
if (!target.isOverflow) { | |
this.checkCollision(target); | |
return; | |
} | |
} | |
} | |
} | |
get bulkLayoutData() { | |
this._createVSortedMembers(); | |
const m = this._vSortedMembers; | |
const result = []; | |
for (let i = 0, len = m.length; i < len; i++) { | |
result.push(m[i].bulkLayoutData); | |
} | |
return result; | |
} | |
set bulkLayoutData(data) { | |
const m = this._vSortedMembers; | |
for (let i = 0, len = m.length; i < len; i++) { | |
m[i].bulkLayoutData = data[i]; | |
} | |
} | |
get bulkSlotData() { | |
this._createVSortedMembers(); | |
let m = this._vSortedMembers; | |
let result = []; | |
for (let i = 0, len = m.length; i < len; i++) { | |
let o = m[i]; | |
result.push({ | |
id: o.id, | |
slot: o.slot, | |
fork: o.fork, | |
no: o.no, | |
vpos: o.vpos, | |
begin: o.inviewTiming, | |
end: o.endRightTiming, | |
invisible: o.isInvisible | |
}); | |
} | |
return result; | |
} | |
set bulkSlotData(data) { | |
let m = this._vSortedMembers; | |
for (let i = 0, len = m.length; i < len; i++) { | |
m[i].slot = data[i].slot; | |
} | |
} | |
_createVSortedMembers() { | |
this._vSortedMembers = this._members.concat().sort(NicoChat.SORT_FUNCTION); | |
return this._vSortedMembers; | |
} | |
get members() {return this._members;} | |
get inViewMembers() {return this.getInViewMembersBySecond(this.currentTime);} | |
getInViewMembersBySecond(sec) { | |
let result = [], m = this._vSortedMembers, len = m.length; | |
for (let i = 0; i < len; i++) { | |
let chat = m[i]; //, s = m.getBeginLeftTiming(); | |
if (chat.isInViewBySecond(sec)) { | |
result.push(chat); | |
} | |
} | |
return result; | |
} | |
getInViewMembersByVpos(vpos) { | |
if (!this._hasLayout) { | |
this._layout(); | |
} | |
return this.getInViewMembersBySecond(vpos / 100); | |
} | |
export() { | |
let result = [], m = this._members, len = m.length; | |
result.push(['\t<group ', | |
'type="', this._nicoChatGroup.type, '" ', | |
'length="', m.length, '" ', | |
'>' | |
].join('')); | |
for (let i = 0; i < len; i++) { | |
result.push(m[i].export()); | |
} | |
result.push('\t</group>'); | |
return result.join('\n'); | |
} | |
getCurrentTime() {return this.currentTime;} | |
getType() {return this.type;} | |
} | |
const updateSpeedRate = () => { | |
let rate = Config.props.commentSpeedRate * 1; | |
if (Config.props.autoCommentSpeedRate) { | |
rate = rate / Math.max(Config.props.playbackRate, 1); | |
} | |
if (rate !== NicoChatViewModel.SPEED_RATE) { | |
NicoChatViewModel.SPEED_RATE = rate; | |
NicoChatViewModel.emitter.emit('updateCommentSpeedRate', rate); | |
} | |
}; | |
Config.onkey('commentSpeedRate', updateSpeedRate); | |
Config.onkey('autoCommentSpeedRate', updateSpeedRate); | |
Config.onkey('playbackRate', updateSpeedRate); | |
updateSpeedRate(); | |
class NicoCommentCss3PlayerView extends Emitter { | |
constructor(params) { | |
super(); | |
this._viewModel = params.viewModel; | |
this._viewModel.on('setData', this._onSetData.bind(this)); | |
this._viewModel.on('currentTime', this._onCurrentTime.bind(this)); | |
this._lastCurrentTime = 0; | |
this._isShow = true; | |
this._aspectRatio = 9 / 16; | |
this._inViewTable = new Set(); | |
this._inSlotTable = new Set(); | |
this._domTable = new Map(); | |
this._playbackRate = params.playbackRate || 1.0; | |
this._isPaused = undefined; | |
this._retryGetIframeCount = 0; | |
console.log('NicoCommentCss3PlayerView playbackRate', this._playbackRate); | |
this._initializeView(params, 0); | |
this._config = Config.namespace('commentLayer'); | |
this._updateDom = throttle.raf(this._updateDom.bind(this)); | |
document.addEventListener('visibilitychange', () => { | |
if (document.visibilityState === 'visible') { | |
this.refresh(); | |
this.onResize(); | |
} | |
}); | |
global.debug.css3Player = this; | |
} | |
_initializeView (params, retryCount) { | |
if (retryCount === 0) { | |
self.console.time('initialize NicoCommentCss3PlayerView'); | |
} | |
this._style = null; | |
this.commentLayer = null; | |
this._view = null; | |
const iframe = this._getIframe(); | |
iframe.loading = 'eager'; | |
iframe.setAttribute('sandbox', 'allow-same-origin'); | |
iframe.className = 'commentLayerFrame'; | |
const html = | |
NicoCommentCss3PlayerView.__TPL__ | |
.replace('%CSS%', '').replace('%MSG%', '') | |
.replace('%LAYOUT_CSS%', NicoTextParser.__css__) | |
.replace('%OPTION_CSS%', ''); | |
const onload = () => { | |
let win, doc; | |
iframe.onload = null; | |
if (env.isChrome()) {iframe.removeAttribute('srcdoc');} | |
try { | |
win = iframe.contentWindow; | |
doc = iframe.contentWindow.document; | |
} catch (e) { | |
self.console.error(e); | |
self.console.log('変な広告に乗っ取られました'); | |
iframe.remove(); | |
this._view = null; | |
global.debug.commentLayer = null; | |
if (retryCount < 3) { | |
this._initializeView(params, retryCount + 1); | |
} else { | |
PopupMessage.alert('コメントレイヤーの生成に失敗'); | |
} | |
return; | |
} | |
this.window = win; | |
this.document = doc; | |
this.fragment = doc.createDocumentFragment(); | |
this.subFragment = doc.createDocumentFragment(); | |
this.removingElements = win.Array(); | |
this._optionStyle = doc.getElementById('optionCss'); | |
this._style = doc.getElementById('nicoChatAnimationDefinition'); | |
const commentLayer = this.commentLayer = doc.getElementById('commentLayer'); | |
const commentLayerOuter = doc.getElementById('commentLayerOuter'); | |
const subLayer = this.subLayer = doc.createElement('div'); | |
subLayer.className = 'subLayer'; | |
commentLayer.append(subLayer); | |
ClassList(doc.body).toggle('debug', Config.props.debug); | |
Config.onkey('debug', v => ClassList(doc.body).toggle('debug', v)); | |
NicoComment.offscreenLayer.get().then(layer => { this._optionStyle.innerHTML = layer.optionCss; }); | |
global.emitter.on('updateOptionCss', newCss => { | |
this._optionStyle.innerHTML = newCss; | |
}); | |
global.debug.getInViewElements = () => doc.getElementsByClassName('nicoChat'); | |
const onResize = () => { | |
const w = win.innerWidth, h = win.innerHeight; | |
if (!w || !h) { return; } | |
const aspectRatio = Math.max(this._aspectRatio, 9 / 16); | |
const targetHeight = Math.min(h, w * aspectRatio); | |
const scale = targetHeight / 384; | |
cssUtil.setProps([commentLayerOuter, '--layer-scale', cssUtil.number(scale)]); | |
}; | |
const chkSizeInit = () => { | |
const h = win.innerHeight; | |
if (!h) { | |
window.setTimeout(chkSizeInit, 500); | |
} else { | |
watchResize(iframe, _.throttle(onResize, 100)); | |
this.onResize = onResize; | |
onResize(); | |
} | |
}; | |
global.emitter.on('fullscreenStatusChange', _.debounce(onResize, 2000)); | |
window.setTimeout(chkSizeInit, 100); | |
if (this._isPaused) { | |
this.pause(); | |
} | |
const updateTextShadow = type => { | |
const types = ['shadow-type2', 'shadow-type3', 'shadow-stroke', 'shadow-dokaben']; | |
const cl = ClassList(doc.body); | |
types.forEach(t => cl.toggle(t, t === type)); | |
}; | |
updateTextShadow(this._config.props.textShadowType); | |
this._config.onkey('textShadowType', _.debounce(updateTextShadow, 100)); | |
this._config.onkey('easyCommentOpacity', | |
_.debounce( | |
v => { | |
console.nicoru('update easyCommentOpacity', v, this._config.easyCommentOpacity, commentLayerOuter); | |
cssUtil.setProps( | |
[commentLayerOuter, '--easy-comment-opacity', cssUtil.number(v * 1)]); | |
} | |
, 100) | |
); | |
self.console.timeEnd('initialize NicoCommentCss3PlayerView'); | |
}; | |
this._view = iframe; | |
if (this._node) { | |
this._node.append(iframe); | |
} | |
if (iframe.srcdocType === 'string') { | |
iframe.onload = onload; | |
iframe.srcdoc = html; | |
} else { | |
if (!this._node) { | |
this._msEdge = true; | |
document.querySelector('.zenzaPlayerContainer').append(iframe); | |
} | |
const icd = iframe.contentWindow.document; | |
icd.open(); | |
icd.write(html); | |
icd.close(); | |
window.setTimeout(onload, 0); | |
} | |
global.debug.commentLayer = iframe; | |
if (!params.show) { | |
this.hide(); | |
} | |
} | |
_getIframe () { | |
const iframe = document.createElement('iframe'); | |
iframe.srcdocType = iframe.srcdocType || (typeof iframe.srcdoc); | |
iframe.srcdoc = '<html></html>'; | |
return iframe; | |
} | |
_onCommand (command, param) { | |
this.emit('command', command, param); | |
} | |
_adjust () { | |
if (!this._view) { | |
return; | |
} | |
if (typeof this.onResize === 'function') { | |
return this.onResize(); | |
} | |
} | |
getView () { | |
return this._view; | |
} | |
set playbackRate (playbackRate) { | |
this._playbackRate = Math.min(Math.max(playbackRate, 0.01), 10); | |
if (!Config.props.autoCommentSpeedRate || this._playbackRate <= 1) { | |
this.refresh(); | |
} | |
} | |
get playbackRate() { return this._playbackRate; } | |
setAspectRatio (ratio) { | |
this._aspectRatio = ratio; | |
this._adjust(); | |
} | |
_onSetData () { | |
this.clear(); | |
} | |
_onCurrentTime (sec) { | |
const REFRESH_THRESHOLD = 1; | |
this._lastCurrentTime = this._currentTime; | |
this._currentTime = sec; | |
if (this._lastCurrentTime === this._currentTime) { | |
if (!this._isPaused) { | |
this._setStall(true); | |
} | |
} else if (this._currentTime < this._lastCurrentTime || | |
Math.abs(this._currentTime - this._lastCurrentTime) > REFRESH_THRESHOLD) { | |
this.refresh(); | |
} else { | |
this._setStall(false); | |
this._updateInviewElements(); | |
} | |
} | |
_addClass (name) { | |
if (!this.commentLayer) { | |
return; | |
} | |
ClassList(this.commentLayer).add(name); | |
} | |
_removeClass (name) { | |
if (!this.commentLayer) { | |
return; | |
} | |
ClassList(this.commentLayer).remove(name); | |
} | |
_setStall (v) { | |
this.isStalled = !!v; | |
if (v) { | |
this._addClass('is-stalled'); | |
} else { | |
this._removeClass('is-stalled'); | |
} | |
} | |
pause () { | |
if (this.commentLayer) { | |
this._addClass('paused'); | |
} | |
this._isPaused = true; | |
} | |
play () { | |
if (this.commentLayer) { | |
this._removeClass('paused'); | |
} | |
this._isPaused = false; | |
} | |
clear () { | |
if (this.commentLayer) { | |
this.commentLayer.textContent = ''; | |
this.subLayer.textContent = ''; | |
this.commentLayer.append(this.subLayer); | |
this.fragment.textContent = ''; | |
this.subFragment.textContent = ''; | |
} | |
if (this._style) { | |
this._style.textContent = ''; | |
} | |
this._inViewTable.clear(); | |
this._inSlotTable.clear(); | |
this._domTable.clear(); | |
this.isUpdating = false; | |
} | |
refresh () { | |
this.clear(); | |
this._updateInviewElements(); | |
} | |
_updateInviewElements () { | |
if (this.isUpdating || !this.commentLayer || !this._style || !this._isShow || document.hidden) { | |
return; | |
} | |
const vm = this._viewModel; | |
const inView = [ | |
vm.getGroup(NicoChat.TYPE.NAKA).inViewMembers, | |
vm.getGroup(NicoChat.TYPE.BOTTOM).inViewMembers, | |
vm.getGroup(NicoChat.TYPE.TOP).inViewMembers | |
].flat(); | |
const dom = [], subDom = [], newView = []; | |
const inSlotTable = this._inSlotTable, inViewTable = this._inViewTable; | |
const ct = this._currentTime; | |
for (let i = 0, len = inView.length; i < len; i++) { | |
const nicoChat = inView[i]; | |
if (inViewTable.has(nicoChat)) { | |
continue; | |
} | |
inViewTable.add(nicoChat); | |
inSlotTable.add(nicoChat); | |
newView.push(nicoChat); | |
} | |
if (newView.length > 1) { | |
newView.sort(NicoChat.SORT_FUNCTION); | |
} | |
const doc = this.document, playbackRate = this._playbackRate; | |
const domTable = this._domTable; | |
for (let i = 0, len = newView.length; i < len; i++) { | |
const nicoChat = newView[i]; | |
const type = nicoChat.type; | |
const size = nicoChat.size; | |
const cssText = NicoChatCss3View.buildChatCss(nicoChat, type, ct, playbackRate); | |
const element = NicoChatCss3View.buildChatDom(nicoChat, type, size, cssText, doc); | |
domTable.set(nicoChat, element); | |
(nicoChat.isSubThread ? subDom : dom).push(element); | |
} | |
if (!newView.length) { | |
return; | |
} | |
this.isUpdating = true; | |
dom.length && this.fragment.append(...dom); | |
subDom.length && this.subFragment.append(...subDom); | |
const currentTime = this._currentTime; | |
const margin = 2 * NicoChatViewModel.SPEED_RATE; | |
for (const nicoChat of inSlotTable) { | |
if (currentTime - margin < nicoChat.endRightTiming) { | |
continue; | |
} | |
const elm = domTable.get(nicoChat); | |
elm && this.removingElements.push(elm); | |
inSlotTable.delete(nicoChat); | |
} | |
this._updateDom(); | |
} | |
_updateDom() { | |
if (this.fragment.firstElementChild) { | |
this.commentLayer.append(this.fragment); | |
} | |
if (this.subFragment.firstElementChild) { | |
this.subLayer.append(this.subFragment); | |
} | |
this._gcInviewElements(); | |
if (this.removingElements.length) { | |
for (const e of this.removingElements) { e.remove(); } | |
this.removingElements.length = 0; | |
} | |
for (const e of this.commentLayer.querySelectorAll('.hidden')) { | |
e.classList.remove('hidden'); | |
e.style.contentVisibility = 'visible'; | |
} | |
this.isUpdating = false; | |
} | |
/* | |
* 古い順に要素を除去していく | |
*/ | |
_gcInviewElements () { | |
if (!this.commentLayer || !this._style) { | |
return; | |
} | |
const max = NicoCommentCss3PlayerView.MAX_DISPLAY_COMMENT; | |
const commentLayer = this.commentLayer; | |
const elements = this.removingElements; | |
const af = this.window.Array.from; // prototype.js汚染を警戒 | |
let inViewElements = // 表示上限オーバー時、かんたんコメントが優先的に消えるように | |
af(commentLayer.querySelectorAll('.nicoChat.fork2')) | |
.concat(af(commentLayer.querySelectorAll('.nicoChat.fork0'))); | |
for (let i = inViewElements.length - max - 1; i >= 0; i--) { | |
elements.push(inViewElements[i]); | |
} | |
} | |
buildHtml (currentTime) { | |
self.console.time('buildHtml'); | |
const vm = this._viewModel; | |
currentTime = currentTime || vm.currentTime; | |
const members = [ | |
vm.getGroup(NicoChat.TYPE.NAKA).members, | |
vm.getGroup(NicoChat.TYPE.BOTTOM).members, | |
vm.getGroup(NicoChat.TYPE.TOP).members | |
].flat(); | |
members.sort(NicoChat.SORT_FUNCTION); | |
const html = []; | |
html.push(this._buildGroupHtml(members, currentTime)); | |
const tpl = NicoCommentCss3PlayerView.__TPL__ | |
.replace('%LAYOUT_CSS%', NicoTextParser.__css__) | |
.replace('%OPTION_CSS%', NicoComment.offscreenLayer.optionCss) | |
.replace('%CSS%', '') | |
.replace('%MSG%', html.join('')); | |
self.console.timeEnd('buildHtml'); | |
return tpl; | |
} | |
_buildGroupHtml (m, currentTime = 0) { | |
const result = []; | |
for (let i = 0, len = m.length; i < len; i++) { | |
const chat = m[i]; | |
const type = chat.type; | |
const cssText = NicoChatCss3View.buildChatCss(chat, type, currentTime); | |
const element = NicoChatCss3View.buildChatHtml(chat, type, cssText, this.document); | |
result.push(element); | |
} | |
return result.join('\n'); | |
} | |
_buildGroupCss (m, currentTime) { | |
const result = []; | |
for (let i = 0, len = m.length; i < len; i++) { | |
const chat = m[i]; | |
const type = chat.type; | |
result.push(NicoChatCss3View.buildChatCss(chat, type, currentTime)); | |
} | |
return result.join('\n'); | |
} | |
show () { | |
if (!this._isShow) { | |
this._isShow = true; | |
this.refresh(); | |
} | |
} | |
hide () { | |
this.clear(); | |
this._isShow = false; | |
} | |
appendTo (node) { | |
if (this._msEdge) { | |
return; | |
} // MS IE/Edge... | |
this._node = node; | |
node.append(this._view); | |
} | |
export () { | |
return this.buildHtml(0) | |
.replace('<html', '<html class="saved"'); | |
} | |
getCurrentScreenHtml () { | |
const win = this.window; | |
if (!win) { | |
return null; | |
} | |
this.refresh(); | |
const body = win.document.body; | |
body.classList.add('in-capture'); | |
let html = win.document.querySelector('html').outerHTML; | |
body.classList.remove('in-capture'); | |
html = html | |
.replace('<html ', '<html xmlns="http://www.w3.org/1999/xhtml" ') | |
.replace(/<meta.*?>/g, '') | |
.replace(/data-meta=".*?"/g, '') | |
.replace(/<br>/g, '<br/>'); | |
return html; | |
} | |
getCurrentScreenSvg () { | |
const win = this.window; | |
if (!win) { | |
return null; | |
} | |
this.refresh(); | |
let body = win.document.body; | |
body.classList.add('in-capture'); | |
let style = win.document.querySelector('style').innerHTML; | |
const w = 682, h = 382; | |
const head = | |
(`<svg | |
xmlns="http://www.w3.org/2000/svg" | |
version="1.1"> | |
`); | |
const defs = ` | |
<defs> | |
<style type="text/css" id="layoutCss"><![CDATA[ | |
${style} | |
.nicoChat { | |
animation-play-state: paused !important; | |
} | |
]]> | |
</style> | |
</defs> | |
`.trim(); | |
const textList = []; | |
Array.from(win.document.querySelectorAll('.nicoChat')).forEach(chat => { | |
let j = JSON.parse(chat.getAttribute('data-meta')); | |
chat.removeAttribute('data-meta'); | |
chat.setAttribute('y', j.ypos); | |
let c = chat.outerHTML; | |
c = c.replace(/<span/g, '<text'); | |
c = c.replace(/<\/span>$/g, '</text>'); | |
c = c.replace(/<(\/?)(span|group|han_group|zen_group|spacer)/g, '<$1tspan'); | |
c = c.replace(/<br>/g, '<br/>'); | |
textList.push(c); | |
}); | |
const view = | |
(` | |
<g fill="#00ff00"> | |
${textList.join('\n\t')} | |
</g> | |
`); | |
const foot = | |
(` | |
<g style="background-color: #333; overflow: hidden; width: ${w}; height: ${h}; padding: 0 69px;" class="shadow-dokaben in-capture paused"> | |
<g id="commentLayerOuter" class="commentLayerOuter" width="682" height="384"> | |
<g class="commentLayer is-stalled" id="commentLayer" width="544" height="384"> | |
</g> | |
</g> | |
</g> | |
</svg> `).trim(); | |
return `${head}${defs}${view}${foot}`; | |
} | |
} | |
NicoCommentCss3PlayerView.MAX_DISPLAY_COMMENT = 40; | |
/* eslint-disable */ | |
NicoCommentCss3PlayerView.__TPL__ = ((Config) => { | |
let ownerShadowColor = Config.props['commentLayer.ownerCommentShadowColor']; | |
ownerShadowColor = ownerShadowColor.replace(/([^a-z^0-9^#])/ig, ''); | |
let easyCommentOpacity = Config.props['commentLayer.easyCommentOpacity']; | |
let textShadowColor = '#000'; | |
let textShadowGray = '#888'; | |
return (` | |
<!DOCTYPE html> | |
<html lang="ja" | |
style="background-color: unset !important; background: none !important;" | |
> | |
<head> | |
<meta charset="utf-8"> | |
<title>CommentLayer</title> | |
<style type="text/css" id="layoutCss">%LAYOUT_CSS%</style> | |
<style type="text/css" id="optionCss">%OPTION_CSS%</style> | |
<style type="text/css"> | |
body { | |
pointer-events: none; | |
user-select: none; | |
overflow: hidden; | |
margin: 0; | |
padding: 0; | |
border: 0; | |
} | |
body.in-capture .commentLayerOuter { | |
overflow: hidden; | |
width: 682px; | |
height: 384px; | |
padding: 0 69px; | |
} | |
body.in-capture .commentLayer { | |
transform: none !important; | |
} | |
.mode-3d .commentLayer { | |
perspective: 50px; | |
} | |
.saved body { | |
pointer-events: auto; | |
} | |
.debug .mincho { background: rgba(128, 0, 0, 0.3); } | |
.debug .gulim { background: rgba(0, 128, 0, 0.3); } | |
.debug .mingLiu { background: rgba(0, 0, 128, 0.3); } | |
@keyframes fixed { | |
0% { opacity: 1; visibility: visible; } | |
95% { opacity: 1; } | |
100% { opacity: 0; visibility: hidden;} | |
} | |
@keyframes show-hide { | |
0% { visibility: visible; opacity: 1; } | |
/* Chrome 73のバグ?対策 hidden が適用されない */ | |
95% { visibility: visible; opacity: 1; } | |
100% { visibility: hidden; opacity: 0; } | |
/*100% { visibility: hidden; }*/ | |
} | |
@keyframes dokaben { | |
0% { | |
visibility: visible; | |
transform: translate3d(-50%, 0, 0) perspective(200px) rotateX(90deg) scale(var(--dokaben-scale)); | |
} | |
50% { | |
transform: translate3d(-50%, 0, 0) perspective(200px) rotateX(0deg) scale(var(--dokaben-scale)); | |
} | |
90% { | |
transform: translate3d(-50%, 0, 0) perspective(200px) rotateX(0deg) scale(var(--dokaben-scale)); | |
} | |
100% { | |
visibility: hidden; | |
transform: translate3d(-50%, 0, 0) perspective(200px) rotateX(90deg) scale(var(--dokaben-scale)); | |
} | |
} | |
@keyframes idou-props { | |
0% { | |
visibility: visible; | |
transform: translateX(0); | |
} | |
100% { | |
visibility: hidden; | |
transform: translateX(var(--chat-trans-x)); | |
} | |
} | |
@keyframes idou-props-scaled { | |
0% { | |
visibility: visible; | |
transform: | |
translateX(0) | |
scale(var(--chat-scale-x), var(--chat-scale-y)); | |
} | |
100% { | |
visibility: hidden; | |
transform: | |
translateX(var(--chat-trans-x)) | |
scale(var(--chat-scale-x), var(--chat-scale-y)); | |
} | |
} | |
@keyframes idou-props-scaled-middle { | |
0% { | |
visibility: visible; | |
transform: | |
translateX(0) | |
scale(var(--chat-scale-x), var(--chat-scale-y)) | |
translateY(-50%); | |
} | |
100% { | |
visibility: hidden; | |
transform: | |
translateX(var(--chat-trans-x)) | |
scale(var(--chat-scale-x), var(--chat-scale-y)) | |
translateY(-50%); | |
} | |
} | |
.commentLayerOuter { | |
position: fixed; | |
top: 50vh; | |
left: 50vw; | |
width: 672px; | |
height: 384px; | |
transform: translate3d(-${672 / 2}px, -${384 / 2}px, 0); | |
contain: layout style size; | |
} | |
.saved .commentLayerOuter { | |
background: #333; | |
position: absolute; | |
top: auto; right: auto; bottom: auto; | |
left: 50%; | |
transform: translate(-50%, 0); | |
contain: layout style size; | |
overflow: visible; | |
} | |
.commentLayer { | |
position: absolute; | |
width: 544px; | |
height: 384px; | |
left: 50%; | |
top: 50%; | |
will-change: transform; | |
transform: translate(-${544 / 2}px, -${384 / 2}px) scale(var(--layer-scale, 1)); | |
contain: layout style size; | |
} | |
.subLayer { | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
opacity: 0.7; | |
contain: layout style size; | |
} | |
.debug .commentLayer { | |
outline: 1px solid green; | |
} | |
.nicoChat { | |
position: absolute; | |
display: inline-block; | |
line-height: 1.235; | |
visibility: hidden; | |
text-shadow: 1px 1px 0 ${textShadowColor}; | |
transform-origin: 0 0; | |
animation-timing-function: linear; | |
/*animation-fill-mode: forwards;*/ | |
will-change: transform; | |
contain: layout style paint; | |
color: #fff; | |
/*-webkit-font-smoothing: initial; | |
font-smooth: auto; | |
text-rendering: optimizeSpeed; | |
font-kerning: none;*/ | |
} | |
.shadow-type2 .nicoChat { | |
text-shadow: | |
1px 1px 0 rgba(0, 0, 0, 0.5), | |
-1px 1px 0 rgba(0, 0, 0, 0.5), | |
-1px -1px 0 rgba(0, 0, 0, 0.5), | |
1px -1px 0 rgba(0, 0, 0, 0.5); | |
} | |
.shadow-type3 .nicoChat { | |
text-shadow: | |
1px 1px 1px rgba( 0, 0, 0, 0.8), | |
0 0 2px rgba( 0, 0, 0, 0.8), | |
-1px -1px 1px rgba(128, 128, 128, 0.8); | |
} | |
.shadow-stroke .nicoChat { | |
text-shadow: none; | |
-webkit-text-stroke: 1px rgba(0, 0, 0, 0.7); | |
text-stroke: 1px rgba(0, 0, 0, 0.7); | |
} | |
/*「RGBは大体 文字200、80、0 縁150,50,0 くらい」らしい*/ | |
.shadow-dokaben .nicoChat.ue, | |
.shadow-dokaben .nicoChat.shita { | |
color: rgb(200, 80, 0); | |
font-family: 'dokaben_ver2_1' !important; | |
font-weight: bolder; | |
animation-name: dokaben !important; | |
text-shadow: | |
1px 1px 0 rgba(150, 50, 0, 1), | |
-1px 1px 0 rgba(150, 50, 0, 1), | |
-1px -1px 0 rgba(150, 50, 0, 1), | |
1px -1px 0 rgba(150, 50, 0, 1) !important; | |
transform-origin: center bottom; | |
animation-timing-function: steps(10); | |
perspective-origin: center bottom; | |
} | |
.shadow-dokaben .nicoChat.ue *, | |
.shadow-dokaben .nicoChat.shita * { | |
font-family: 'dokaben_ver2_1' !important; | |
} | |
.shadow-dokaben .nicoChat { | |
text-shadow: | |
1px 1px 0 rgba(0, 0, 0, 0.5), | |
-1px 1px 0 rgba(0, 0, 0, 0.5), | |
-1px -1px 0 rgba(0, 0, 0, 0.5), | |
1px -1px 0 rgba(0, 0, 0, 0.5); | |
} | |
.nicoChat.ue, .nicoChat.shita { | |
animation-name: fixed; | |
visibility: hidden; | |
will-change: transform, opacity; | |
} | |
.nicoChat.ue.html5, .nicoChat.shita.html5 { | |
animation-name: show-hide; | |
animation-timing-function: steps(20, jump-none); | |
} | |
.nicoChat.black, .nicoChat.black.fork1 { | |
text-shadow: | |
-1px -1px 0 ${textShadowGray}, | |
1px 1px 0 ${textShadowGray}; | |
} | |
.nicoChat.ue, | |
.nicoChat.shita { | |
display: inline-block; | |
text-shadow: 0 0 3px #000; | |
} | |
.nicoChat.ue.black, | |
.nicoChat.shita.black { | |
text-shadow: 0 0 3px #fff; | |
} | |
.nicoChat .type0655, | |
.nicoChat .zero_space { | |
text-shadow: none; | |
-webkit-text-stroke: unset; | |
opacity: 0; | |
} | |
.nicoChat .han_space, | |
.nicoChat .zen_space { | |
text-shadow: none; | |
-webkit-text-stroke: unset; | |
opacity: 0; | |
} | |
.debug .nicoChat .han_space, | |
.debug .nicoChat .zen_space { | |
text-shadow: none; | |
-webkit-text-stroke: unset; | |
color: yellow; | |
background: #fff; | |
opacity: 0.3; | |
} | |
.debug .nicoChat .tab_space { | |
text-shadow: none; | |
-webkit-text-stroke: unset; | |
background: #ff0; | |
opacity: 0.3; | |
} | |
.nicoChat .invisible_code { | |
text-shadow: none; | |
-webkit-text-stroke: unset; | |
opacity: 0; | |
} | |
.nicoChat .zero_space { | |
text-shadow: none; | |
-webkit-text-stroke: unset; | |
opacity: 0; | |
} | |
.debug .nicoChat .zero_space { | |
display: inline; | |
position: absolute; | |
} | |
.debug .html5_zen_space { | |
color: #888; | |
opacity: 0.5; | |
} | |
.nicoChat .fill_space, .nicoChat .html5_fill_space { | |
text-shadow: none; | |
-webkit-text-stroke: unset !important; | |
text-stroke: unset !important; | |
background: currentColor; | |
} | |
.nicoChat .mesh_space { | |
text-shadow: none; | |
-webkit-text-stroke: unset; | |
} | |
.nicoChat .block_space, .nicoChat .html5_block_space { | |
text-shadow: none; | |
} | |
.debug .nicoChat.ue { | |
text-decoration: overline; | |
} | |
.debug .nicoChat.shita { | |
text-decoration: underline; | |
} | |
.nicoChat.mine { | |
border: 1px solid yellow; | |
} | |
.nicoChat.nicotta { | |
border: 1px solid orange; | |
} | |
.nicoChat.updating { | |
border: 1px dotted; | |
} | |
.nicoChat.fork1 { | |
text-shadow: | |
1px 1px 0 ${ownerShadowColor}, | |
-1px -1px 0 ${ownerShadowColor}; | |
-webkit-text-stroke: unset; | |
} | |
.nicoChat.ue.fork1, | |
.nicoChat.shita.fork1 { | |
display: inline-block; | |
text-shadow: 0 0 3px ${ownerShadowColor}; | |
-webkit-text-stroke: unset; | |
} | |
.nicoChat.fork2 { | |
opacity: var(--easy-comment-opacity, ${easyCommentOpacity}) !important; | |
} | |
.nicoChat.blink { | |
border: 1px solid #f00; | |
} | |
.nicoChat.subThread { | |
filter: opacity(0.7); | |
} | |
@keyframes spin { | |
0% { transform: rotate(0deg); } | |
100% { transform: rotate(3600deg); } | |
} | |
.nicoChat.updating::before { | |
content: '❀'; | |
opacity: 0.8; | |
color: #f99; | |
display: inline-block; | |
text-align: center; | |
animation-name: spin; | |
animation-iteration-count: infinite; | |
animation-duration: 10s; | |
} | |
.nicoChat.updating::after { | |
content: ' 通信中...'; | |
font-size: 50%; | |
opacity: 0.8; | |
color: #ccc; | |
} | |
.nicoChat.updating::after { | |
animation-direction: alternate; | |
} | |
.nicoChat.fail { | |
border: 1px dotted red; | |
text-decoration: line-through; | |
} | |
.nicoChat.fail:after { | |
content: ' 投稿失敗...'; | |
text-decoration: none; | |
font-size: 80%; | |
opacity: 0.8; | |
color: #ccc; | |
} | |
.debug .nicoChat { | |
outline: 1px outset; | |
} | |
spacer { | |
visibility: hidden; | |
} | |
.debug spacer { | |
visibility: visible; | |
outline: 3px dotted orange; | |
} | |
.is-stalled *, | |
.paused *{ | |
animation-play-state: paused !important; | |
} | |
</style> | |
<style id="nicoChatAnimationDefinition"> | |
%CSS% | |
</style> | |
</head> | |
<body style="background-color: unset !important; background: none !important;"> | |
<div hidden="true" id="keyframesContainer"></div> | |
<div id="commentLayerOuter" class="commentLayerOuter"> | |
<div class="commentLayer" id="commentLayer">%MSG%</div> | |
</div> | |
</body></html> | |
`).trim(); | |
})(Config); | |
Object.assign(global.debug, { | |
NicoChat, | |
NicoChatViewModel | |
}); | |
const CommentLayoutWorker = (config => { | |
const func = function(self) { | |
const TYPE = { | |
TOP: 'ue', | |
NAKA: 'naka', | |
BOTTOM: 'shita' | |
}; | |
const SCREEN = { | |
WIDTH_INNER: 512, | |
WIDTH_FULL_INNER: 640, | |
WIDTH: 512 + 32, | |
WIDTH_FULL: 640 + 32, | |
HEIGHT: 384 | |
}; | |
const isConflict = (target, others) => { | |
if (target.isOverflow || others.isOverflow || others.isInvisible) { | |
return false; | |
} | |
if (target.layerId !== others.layerId) { | |
return false; | |
} | |
const othersY = others.ypos; | |
const targetY = target.ypos; | |
if (othersY + others.height < targetY || | |
othersY > targetY + target.height) { | |
return false; | |
} | |
let rt, lt; | |
if (target.beginLeft <= others.beginLeft) { | |
lt = target; | |
rt = others; | |
} else { | |
lt = others; | |
rt = target; | |
} | |
if (target.isFixed) { | |
if (lt.endRight > rt.beginLeft) { | |
return true; | |
} | |
} else { | |
if (lt.beginRight >= rt.beginLeft) { | |
return true; | |
} | |
if (lt.endRight >= rt.endLeft) { | |
return true; | |
} | |
} | |
return false; | |
}; | |
const moveToNextLine = (self, others) => { | |
const margin = 1; | |
const othersHeight = others.height + margin; | |
const overflowMargin = 10; | |
const rnd = Math.max(0, SCREEN.HEIGHT - self.height); | |
const yMax = SCREEN.HEIGHT - self.height + overflowMargin; | |
const yMin = 0 - overflowMargin; | |
const type = self.type; | |
let ypos = self.ypos; | |
if (type !== TYPE.BOTTOM) { | |
ypos += othersHeight; | |
if (ypos > yMax) { | |
self.isOverflow = true; | |
} | |
} else { | |
ypos -= othersHeight; | |
if (ypos < yMin) { | |
self.isOverflow = true; | |
} | |
} | |
self.ypos = self.isOverflow ? Math.floor(Math.random() * rnd) : ypos; | |
return self; | |
}; | |
const findCollisionStartIndex = (target, members) => { | |
const tl = target.beginLeft; | |
const tr = target.endRight; | |
const layerId = target.layerId; | |
for (let i = 0, len = members.length; i < len; i++) { | |
const o = members[i]; | |
const ol = o.beginLeft; | |
const or = o.endRight; | |
if (o.id === target.id) { | |
return -1; | |
} | |
if (layerId !== o.layerId || o.invisible || o.isOverflow) { | |
continue; | |
} | |
if (tl <= or && tr >= ol) { | |
return i; | |
} | |
} | |
return -1; | |
}; | |
const _checkCollision = (target, members, collisionStartIndex) => { | |
const beginLeft = target.beginLeft; | |
for (let i = collisionStartIndex, len = members.length; i < len; i++) { | |
const o = members[i]; | |
if (o.id === target.id) { | |
return target; | |
} | |
if (beginLeft > o.endRight) { | |
continue; | |
} | |
if (isConflict(target, o)) { | |
target = moveToNextLine(target, o); | |
if (!target.isOverflow) { | |
return _checkCollision(target, members, collisionStartIndex); | |
} | |
} | |
} | |
return target; | |
}; | |
const checkCollision = (target, members) => { | |
if (target.isInvisible) { | |
return target; | |
} | |
const collisionStartIndex = findCollisionStartIndex(target, members); | |
if (collisionStartIndex < 0) { | |
return target; | |
} | |
return _checkCollision(target, members, collisionStartIndex); | |
}; | |
const groupCollision = members => { | |
for (let i = 0, len = members.length; i < len; i++) { | |
checkCollision(members[i], members); | |
} | |
return members; | |
}; | |
self.onmessage = ({command, params}) => { | |
const {type, members, lastUpdate} = params; | |
console.time('CommentLayoutWorker: ' + type); | |
groupCollision(members); | |
console.timeEnd('CommentLayoutWorker: ' + type); | |
return {type, members, lastUpdate}; | |
}; | |
}; | |
let instance = null; | |
return { | |
_func: func, | |
create: () => workerUtil.createCrossMessageWorker(func, {name: 'CommentLayoutWorker'}), | |
getInstance() { | |
if (!instance) { | |
instance = this.create(); | |
} | |
return instance; | |
} | |
}; | |
})(Config); | |
const SlotLayoutWorker = (() => { | |
const func = function (self) { | |
const SLOT_COUNT = 40; | |
class SlotEntry { | |
constructor(slotCount) { | |
this.slotCount = slotCount || SLOT_COUNT; | |
this.slot = []; | |
this.itemTable = {}; | |
this.p = 1; | |
} | |
findIdle(sec) { | |
const {count, slot, table} = this; | |
for (let i = 0; i < count; i++) { | |
if (!slot[i]) { | |
slot[i] = this.p++; | |
return i; | |
} | |
let item = table[i]; | |
if (sec < item.begin || sec > item.end) { | |
slot[i] = this.p++; | |
return i; | |
} | |
} | |
return -1; | |
} | |
get mostOld() { | |
let idx = 0, slot = this.slot, min = slot[0]; | |
for (let i = 1, len = this.slot.length; i < len; i++) { | |
if (slot[i] < min) { | |
min = slot[i]; | |
idx = i; | |
} | |
} | |
return idx; | |
} | |
find(item, sec) { | |
let slot = this.findIdle(sec); | |
if (slot < 0) { | |
slot = this.mostOld; | |
} | |
this.itemTable[slot] = item; | |
return slot; | |
} | |
} | |
const sortByBeginTime = data => { | |
data = data.concat().sort((a, b) => { | |
const av = a.begin, bv = b.begin; | |
if (av !== bv) { | |
return av - bv; | |
} else { | |
return a.no < b.no ? -1 : 1; | |
} | |
}); | |
return data; | |
}; | |
const execute = ({top, naka, bottom}) => { | |
const data = sortByBeginTime([top, naka, bottom].flat()); | |
const slotEntries = [new SlotEntry(), new SlotEntry(), new SlotEntry()]; | |
for (let i = 0, len = data.length; i < len; i++) { | |
const o = data[i]; | |
if (o.invisible) { | |
continue; | |
} | |
const sec = o.begin; | |
const fork = o.fork % 3; | |
o.slot = slotEntries[fork].find(o, sec); | |
} | |
return data; | |
}; | |
self.onmessage = ({command, params}) => { | |
console.time('SlotLayoutWorker'); | |
const result = execute(params); | |
console.timeEnd('SlotLayoutWorker'); | |
result.lastUpdate = params.lastUpdate; | |
return result; | |
}; | |
}; | |
return { | |
_func: func, | |
create: function () { | |
if (!workerUtil.isAvailable) { | |
return null; | |
} | |
return workerUtil.createCrossMessageWorker(func, {name: 'SlotLayoutWorker'}); | |
} | |
}; | |
})(); | |
class NicoScriptParser { | |
static get parseId() { | |
if (!NicoScriptParser._count) { | |
NicoScriptParser._count = 1; | |
} | |
return NicoScriptParser._count++; | |
} | |
static parseNiwango(lines) { | |
let type, params, m; | |
const result = []; | |
for (let i = 0, len = lines.length; i < len; i++) { | |
const text = lines[i]; | |
const id = NicoScriptParser.parseId; | |
if ((m = /^\/?replace\((.*?)\)/.exec(text)) !== null) { | |
type = 'REPLACE'; | |
params = NicoScriptParser.parseReplace(m[1]); | |
result.push({id, type, params}); | |
} else if ((m = /^\/?commentColor\s*=\s*0x([0-9a-f]{6})/i.exec(text)) !== null) { | |
result.push({id, type: 'COLOR', params: {color: '#' + m[1]}}); | |
} else if ((m = /^\/?seek\((.*?)\)/i.exec(text)) !== null) { | |
params = NicoScriptParser.parseSeek(m[1]); | |
result.push({id, type: 'SEEK', params}); | |
} | |
} | |
return result; | |
} | |
static parseParams(str) { | |
let result = {}, v = '', lastC = '', key, isStr = false, quot = ''; | |
for (let i = 0, len = str.length; i < len; i++) { | |
let c = str.charAt(i); | |
switch (c) { | |
case ':': | |
key = v.trim(); | |
v = ''; | |
break; | |
case ',': | |
if (isStr) { | |
v += c; | |
} | |
else { | |
if (key !== '' && v !== '') { | |
result[key] = v.trim(); | |
} | |
key = v = ''; | |
} | |
break; | |
case ' ': | |
if (v !== '') { | |
v += c; | |
} | |
break; | |
case '\'': | |
case '"': | |
if (v !== '') { | |
if (quot !== c) { | |
v += c; | |
} else if (isStr) { | |
if (lastC === '\\') { | |
v += c; | |
} | |
else { | |
if (quot === '"') { | |
v = v.replace(/(\\r|\\n)/g, '\n').replace(/(\\t)/g, '\t'); | |
} | |
result[key] = v; | |
key = v = ''; | |
isStr = false; | |
} | |
} else { | |
window.console.error('parse fail?', isStr, lastC, str); | |
return null; | |
} | |
} else { | |
quot = c; | |
isStr = true; | |
} | |
break; | |
default: | |
v += c; | |
} | |
lastC = c; | |
} | |
if (key !== '' && v !== '') { | |
result[key] = v.trim(); | |
} | |
return result; | |
} | |
static parseNicosParams(str) { | |
let result = [], v = '', lastC = '', quot = ''; | |
for (let i = 0, len = str.length; i < len; i++) { | |
let c = str.charAt(i); | |
switch (c) { | |
case ' ': | |
case ' ': | |
if (quot) { | |
v += c; | |
} else { | |
if (v !== '') { | |
result.push(v); | |
v = quot = ''; | |
} | |
} | |
break; | |
case '\'': | |
case '"': | |
if (v !== '') { | |
if (quot !== c) { | |
v += c; | |
} else { | |
if (lastC === '\\') { | |
v += c; | |
} | |
else { | |
v = v.replace(/(\\r|\\n)/g, '\n').replace(/(\\t)/g, '\t'); | |
result.push(v); | |
v = quot = ''; | |
} | |
} | |
} else { | |
quot = c; | |
} | |
break; | |
case '「': | |
if (v !== '') { | |
v += c; | |
} else { | |
quot = c; | |
} | |
break; | |
case '」': | |
if (v !== '') { | |
if (quot !== '「') { | |
v += c; | |
} else { | |
if (lastC === '\\') { | |
v += c; | |
} | |
else { | |
result.push(v); | |
v = quot = ''; | |
} | |
} | |
} else { | |
v += c; | |
} | |
break; | |
default: | |
v += c; | |
} | |
lastC = c; | |
} | |
if (v !== '') { | |
result.push(v.trim()); | |
} | |
return result; | |
} | |
static parseNicos(text) { | |
text = text.trim(); | |
const text1 = (text || '').split(/[ ::]+/)[0]; // eslint-disable-line | |
let params; | |
let type; | |
switch (text1) { | |
case '@デフォルト': | |
case '@デフォルト': | |
type = 'DEFAULT'; | |
break; | |
case '@逆': | |
case '@逆': | |
type = 'REVERSE'; | |
params = NicoScriptParser.parse逆(text); | |
break; | |
case '@ジャンプ': | |
case '@ジャンプ': | |
params = NicoScriptParser.parseジャンプ(text); | |
type = params.type; | |
break; | |
case '@ジャンプマーカー': | |
case '@ジャンプマーカー': | |
type = 'MARKER'; //@ジャンプマーカー:ループ | |
params = NicoScriptParser.parseジャンプマーカー(text); | |
break; | |
default: | |
if (text.indexOf('@置換') === 0 || text.indexOf('@置換') === 0) { | |
type = 'REPLACE'; | |
params = NicoScriptParser.parse置換(text); | |
} else { | |
type = 'PIPE'; | |
let lines = NicoScriptParser.splitLines(text); | |
params = NicoScriptParser.parseNiwango(lines); | |
} | |
} | |
const id = NicoScriptParser.parseId; | |
return {id, type, params}; | |
} | |
static splitLines(str) { | |
let result = [], v = '', lastC = '', isStr = false, quot = ''; | |
for (let i = 0, len = str.length; i < len; i++) { | |
let c = str.charAt(i); | |
switch (c) { | |
case ';': | |
if (isStr) { | |
v += c; | |
} | |
else { | |
result.push(v.trim()); | |
v = ''; | |
} | |
break; | |
case ' ': | |
if (v !== '') { | |
v += c; | |
} | |
break; | |
case '\'': | |
case '"': | |
if (isStr) { | |
if (quot === c) { | |
if (lastC !== '\\') { | |
isStr = false; | |
} | |
} | |
v += c; | |
} else { | |
quot = c; | |
isStr = true; | |
v += c; | |
} | |
break; | |
default: | |
v += c; | |
} | |
lastC = c; | |
} | |
if (v !== '') { | |
result.push(v.trim()); | |
} | |
return result; | |
} | |
static parseReplace(str) { | |
const result = NicoScriptParser.parseParams(str); | |
if (!result) { | |
return null; | |
} | |
return { | |
src: result.src, | |
dest: result.dest || '', | |
fill: result.fill === 'true' ? true : false, | |
target: result.target || 'user', | |
partial: result.partial === 'false' ? false : true | |
}; | |
} | |
static parseSeek(str) { | |
const result = NicoScriptParser.parseParams(str); | |
if (!result) { | |
return null; | |
} | |
return { | |
time: result.vpos | |
}; | |
} | |
static parse置換(str) { | |
const tmp = NicoScriptParser.parseNicosParams(str); | |
let target = 'user'; // '投コメ' | |
if (tmp[4] === '含む' || tmp[4] === '全') { // マニュアルにはないが '全' もあるらしい | |
target = 'owner user'; | |
} else if (tmp[4] === '投コメ') { | |
target = 'owner'; | |
} | |
return { | |
src: tmp[1], | |
dest: tmp[2] || '', | |
fill: tmp[3] === '全' ? true : false, //全体を置き換えるかどうか | |
target, //(tmp[4] === '含む' || tmp[4] === '投コメ') ? 'owner user' : 'user', | |
partial: tmp[5] === '完全一致' ? false : true // 完全一致のみを見るかどうか | |
}; | |
} | |
static parse逆(str) { | |
const tmp = NicoScriptParser.parseNicosParams(str); | |
/* eslint-disable */ | |
/* eslint-enable */ | |
const target = (tmp[1] || '').trim(); | |
return { | |
target: (target === 'コメ' || target === '投コメ') ? target : '全', | |
}; | |
} | |
static parseジャンプ(str) { | |
const tmp = NicoScriptParser.parseNicosParams(str); | |
const target = tmp[1] || ''; | |
let type = 'JUMP'; | |
let time = 0; | |
let m; | |
if ((m = /^#(\d+):(\d+)$/.exec(target)) !== null) { | |
type = 'SEEK'; | |
time = m[1] * 60 + m[2] * 1; | |
} else if ((m = /^#(\d+):(\d+\.\d+)$/.exec(target)) !== null) { | |
type = 'SEEK'; | |
time = m[1] * 60 + m[2] * 1; | |
} else if ((m = /^(#|#)(.+)/.exec(target)) !== null) { | |
type = 'SEEK_MARKER'; | |
time = m[2]; | |
} | |
return {target, type, time}; | |
} | |
static parseジャンプマーカー(str) { | |
const tmp = NicoScriptParser.parseNicosParams(str); | |
const name = tmp[0].split(/[:: ]/)[1]; // eslint-disable-line | |
return {name}; | |
} | |
} | |
class NicoScripter extends Emitter { | |
constructor() { | |
super(); | |
this.reset(); | |
} | |
reset() { | |
this._hasSort = false; | |
this._list = []; | |
this._eventScript = []; | |
this._nextVideo = null; | |
this._marker = {}; | |
this._inviewEvents = {}; | |
this._currentTime = 0; | |
this._eventId = 0; | |
} | |
add(nicoChat) { | |
this._hasSort = false; | |
this._list.push(nicoChat); | |
} | |
get isEmpty() { | |
return this._list.length === 0; | |
} | |
getNextVideo() { | |
return this._nextVideo || ''; | |
} | |
getEventScript() { | |
return this._eventScript || []; | |
} | |
get currentTime() { | |
return this._currentTime; | |
} | |
set currentTime(v) { | |
this._currentTime = v; | |
if (this._eventScript.length > 0) { | |
this._updateInviewEvents(); | |
} | |
} | |
_sort() { | |
if (this._hasSort) { | |
return; | |
} | |
const list = this._list.concat().sort((a, b) => { | |
const av = a.vpos, bv = b.vpos; | |
if (av !== bv) { | |
return av - bv; | |
} else { | |
return a.no < b.no ? -1 : 1; | |
} | |
}); | |
this._list = list; | |
this._hasSort = true; | |
} | |
_updateInviewEvents() { | |
const ct = this._currentTime; | |
this._eventScript.forEach(({p, nicos}) => { | |
const beginTime = nicos.vpos / 100; | |
const endTime = beginTime + nicos.duration; | |
if (beginTime > ct || endTime < ct) { | |
delete this._inviewEvents[p.id]; | |
return; | |
} | |
if (this._inviewEvents[p.id]) { | |
return; | |
} | |
this._inviewEvents[p.id] = true; | |
let diff = nicos.vpos / 100 - ct; | |
diff = Math.min(1, Math.abs(diff)) * (diff / Math.abs(diff)); | |
switch (p.type) { | |
case 'SEEK': | |
this.emit('command', 'nicosSeek', Math.max(0, p.params.time * 1 + diff)); | |
break; | |
case 'SEEK_MARKER': { | |
let time = this._marker[p.params.time] || 0; | |
this.emit('command', 'nicosSeek', Math.max(0, time + diff)); | |
break; | |
} | |
} | |
}); | |
} | |
apply(group) { | |
this._sort(); | |
const assigned = {}; | |
const eventFunc = { | |
'JUMP': (p, nicos) => { | |
console.log('@ジャンプ: ', p, nicos); | |
const target = p.params.target; | |
if (/^([a-z]{2}|)[0-9]+$/.test(target)) { | |
this._nextVideo = target; | |
} | |
}, | |
'SEEK': (p, nicos) => { | |
if (assigned[p.id]) { | |
return; | |
} | |
assigned[p.id] = true; | |
this._eventScript.push({p, nicos}); | |
}, | |
'SEEK_MARKER': (p, nicos) => { | |
if (assigned[p.id]) { | |
return; | |
} | |
assigned[p.id] = true; | |
console.log('SEEK_MARKER: ', p, nicos); | |
this._eventScript.push({p, nicos}); | |
}, | |
'MARKER': (p, nicos) => { | |
console.log('@ジャンプマーカー: ', p, nicos); | |
this._marker[p.params.name] = nicos.vpos / 100; | |
} | |
}; | |
const applyFunc = { | |
DEFAULT(nicoChat, nicos) { | |
const nicosColor = nicos.color; | |
const hasColor = nicoChat.hasColorCommand; | |
if (nicosColor && !hasColor) { | |
nicoChat.color = nicosColor; | |
} | |
const nicosSize = nicos.size; | |
const hasSize = nicoChat.hasSizeCommand; | |
if (nicosSize && !hasSize) { | |
nicoChat.size = nicosSize; | |
} | |
const nicosType = nicos.type; | |
const hasType = nicoChat.hasTypeCommand; | |
if (nicosType && !hasType) { | |
nicoChat.type = nicosType; | |
} | |
}, | |
COLOR(nicoChat, nicos, params) { | |
const hasColor = nicoChat.hasColorCommand; | |
if (!hasColor) { | |
nicoChat.color = params.color; | |
} | |
}, | |
REVERSE(nicoChat, nicos, params) { | |
if (params.target === '全') { | |
nicoChat.isReverse = true; | |
} else if (params.target === '投コメ') { | |
if (nicoChat.fork > 0) { | |
nicoChat.isReverse = true; | |
} | |
} else if (params.target === 'コメ') { | |
if (nicoChat.fork === 0) { | |
nicoChat.isReverse = true; | |
} | |
} | |
}, | |
REPLACE(nicoChat, nicos, params) { | |
if (!params) { | |
return; | |
} | |
if (nicoChat.fork > 0 && (params.target || '').indexOf('owner') < 0) { | |
return; | |
} | |
if (nicoChat.fork < 1 && params.target === 'owner') { | |
return; | |
} | |
let isMatch = false; | |
let text = nicoChat.text; | |
if (params.partial === true) { | |
isMatch = text.indexOf(params.src) >= 0; | |
} else { | |
isMatch = text === params.src; | |
} | |
if (!isMatch) { | |
return; | |
} | |
if (params.fill === true) { | |
text = params.dest; | |
} else {// @置換 "~" "\n" 単 全 | |
const reg = new RegExp(textUtil.escapeRegs(params.src), 'g'); | |
text = text.replace(reg, params.dest); | |
} | |
nicoChat.text = text; | |
const nicosColor = nicos.clor; | |
const hasColor = nicoChat.hasColorCommand; | |
if (nicosColor && !hasColor) { | |
nicoChat.color = nicosColor; | |
} | |
const nicosSize = nicos.size; | |
const hasSize = nicoChat.hasSizeCommand; | |
if (nicosSize && !hasSize) { | |
nicoChat.size = nicosSize; | |
} | |
const nicosType = nicos.type; | |
const hasType = nicoChat.hasTypeCommand; | |
if (nicosType && !hasType) { | |
nicoChat.type = nicosType; | |
} | |
}, | |
PIPE(nicoChat, nicos, lines) { | |
lines.forEach(line => { | |
const type = line.type; | |
const f = applyFunc[type]; | |
if (f) { | |
f(nicoChat, nicos, line.params); | |
} | |
}); | |
} | |
}; | |
this._list.forEach(nicos => { | |
const p = NicoScriptParser.parseNicos(nicos.text); | |
if (!p) { | |
return; | |
} | |
if (!nicos.hasDurationSet) { | |
nicos.duration = 99999; | |
} | |
const ev = eventFunc[p.type]; | |
if (ev) { | |
return ev(p, nicos); | |
} | |
else if (p.type === 'PIPE') { | |
p.params.forEach(line => { | |
const type = line.type; | |
const ev = eventFunc[type]; | |
if (ev) { | |
return ev(line, nicos); | |
} | |
}); | |
} | |
const func = applyFunc[p.type]; | |
if (!func) { | |
return; | |
} | |
const beginTime = nicos.beginTime; | |
const endTime = beginTime + nicos.duration; | |
(group.members ? group.members : group).forEach(nicoChat => { | |
if (nicoChat.isNicoScript) { | |
return; | |
} | |
const ct = nicoChat.beginTime; | |
if (beginTime > ct || endTime < ct) { | |
return; | |
} | |
func(nicoChat, nicos, p.params); | |
}); | |
}); | |
} | |
} | |
class CommentListModel extends Emitter { | |
constructor(params) { | |
super(); | |
this._isUniq = params.uniq; | |
this._items = []; | |
this._positions = []; | |
this._maxItems = params.maxItems || 100; | |
this._currentSortKey = 'vpos'; | |
this._isDesc = false; | |
this._currentTime = 0; | |
this._currentIndex = -1; | |
} | |
setItem(itemList) { | |
this._items = Array.isArray(itemList) ? itemList : [itemList]; | |
} | |
clear() { | |
this._items = []; | |
this._positions = []; | |
this._currentTime = 0; | |
this._currentIndex = -1; | |
this.emit('update', [], true); | |
} | |
setChatList(chatList) { | |
chatList = chatList.top.concat(chatList.naka, chatList.bottom); | |
const items = []; | |
const positions = []; | |
for (let i = 0, len = chatList.length; i < len; i++) { | |
items.push(new CommentListItem(chatList[i])); | |
positions.push(parseFloat(chatList[i].vpos, 10) / 100); | |
} | |
this._items = items; | |
this._positions = positions.sort((a, b) => a - b); | |
this._currentTime = 0; | |
this._currentIndex = -1; | |
this.sort(); | |
this.emit('update', this._items, true); | |
} | |
removeItemByIndex(index) { | |
const target = this._getItemByIndex(index); | |
if (!target) { | |
return; | |
} | |
this._items = this._items.filter(item => item !== target); | |
} | |
get length() { | |
return this._items.length; | |
} | |
_getItemByIndex(index) { | |
return this._items[index]; | |
} | |
indexOf(item) { | |
return (this._items || []).indexOf(item); | |
} | |
getItemByIndex(index) { | |
const item = this._getItemByIndex(index); | |
if (!item) { | |
return null; | |
} | |
return item; | |
} | |
findByItemId(itemId) { | |
itemId = parseInt(itemId, 10); | |
return this._items.find(item => item.itemId === itemId); | |
} | |
removeItem(item) { | |
const beforeLen = this._items.length; | |
this._items = this._items.filter(i => i !== item); //_.pull(this._items, item); | |
const afterLen = this._items.length; | |
if (beforeLen !== afterLen) { | |
this.emit('update', this._items); | |
} | |
} | |
_onItemUpdate(item, key, value) { | |
this.emit('itemUpdate', item, key, value); | |
} | |
sortBy(key, isDesc) { | |
const table = { | |
vpos: 'vpos', | |
date: 'date', | |
text: 'text', | |
user: 'userId', | |
nicoru: 'nicoru' | |
}; | |
const func = table[key]; | |
if (!func) { | |
return; | |
} | |
this._items = _.sortBy(this._items, item => item[func]); | |
if (isDesc) { | |
this._items.reverse(); | |
} | |
this._currentSortKey = key; | |
this._isDesc = isDesc; | |
this.onUpdate(true); | |
} | |
sort() { | |
this.sortBy(this._currentSortKey, this._isDesc); | |
} | |
get currentSortKey() { | |
return this._currentSortKey; | |
} | |
onUpdate(replaceAll = false) { | |
this.emitAsync('update', this._items, replaceAll); | |
} | |
getInViewIndex(sec) { | |
return Math.max(0, _.sortedLastIndex(this._positions, sec + 1) - 1); | |
} | |
set currentTime(sec) { | |
if (this._currentTime !== sec && typeof sec === 'number') { | |
this._currentTime = sec; | |
const inviewIndex = this.getInViewIndex(sec); | |
if (this._currentSortKey === 'vpos' && this._currentIndex !== inviewIndex) { | |
this.emit('currentTimeUpdate', sec, inviewIndex); | |
} | |
this._currentIndex = inviewIndex; | |
} | |
} | |
get currentTime() {return this._currentTime;} | |
} | |
class CommentListView extends Emitter { | |
constructor(params) { | |
super(); | |
this._ItemView = CommentListItemView; | |
this._itemCss = CommentListItemView.CSS; | |
this._className = params.className || 'commentList'; | |
this._retryGetIframeCount = 0; | |
this._maxItems = 100000; | |
this._inviewItemList = new Map; | |
this._scrollTop = 0; | |
this.timeScrollTop = 0; | |
this.newItems = []; | |
this.removedItems = []; | |
this._innerHeight = 100; | |
this._model = params.model; | |
if (this._model) { | |
this._model.on('update', _.debounce(this._onModelUpdate.bind(this), 500)); | |
} | |
this.setScrollTop = throttle.raf(this.setScrollTop.bind(this)); | |
this._initializeView(params, 0); | |
} | |
async _initializeView(params) { | |
const html = CommentListView.__tpl__.replace('%CSS%', this._itemCss); | |
const frame = this.frameLayer = new FrameLayer({ | |
container: params.container, | |
html, | |
className: 'commentListFrame' | |
}); | |
const contentWindow = await frame.wait(); | |
this._initFrame(contentWindow); | |
} | |
_initFrame(w) { | |
this.contentWindow = w; | |
const doc = this.document = w.document; | |
const body = this.body = doc.body; | |
const classList = this.classList = ClassList(body); | |
const $body = this._$body = uq(body); | |
if (this._className) { | |
classList.add(this._className); | |
} | |
this._container = doc.querySelector('#listContainer'); | |
this._$container = uq(this._container); | |
this._list = doc.getElementById('listContainerInner'); | |
if (this._html) { | |
this._list.innerHTML = this._html; | |
} | |
this._$menu = $body.find('.listMenu'); | |
this._$itemDetail = $body.find('.itemDetailContainer'); | |
$body | |
.on('click', this._onClick.bind(this)) | |
.on('dblclick', this._onDblClick.bind(this)) | |
.on('keydown', e => global.emitter.emit('keydown', e)) | |
.on('keyup', e => global.emitter.emit('keyup', e)) | |
.toggleClass('is-guest', !nicoUtil.isLogin()) | |
.toggleClass('is-premium', nicoUtil.isPremium()) | |
.toggleClass('is-firefox', env.isFirefox()); | |
this.frameLayer.frame.addEventListener('visibilitychange', e => { | |
const {isVisible} = e.detail; | |
if (!isVisible) { return; } | |
if (this.isAutoScroll) { | |
this.setScrollTop(this.timeScrollTop); | |
} | |
this._refreshInviewElements(); | |
}); | |
this._$menu.on('click', this._onMenuClick.bind(this)); | |
this._$itemDetail.on('click', this._onItemDetailClick.bind(this)); | |
this._onScroll = this._onScroll.bind(this); | |
this._onScrolling = _.throttle(this._onScrolling.bind(this), 100); | |
this._onScrollEnd = _.debounce(this._onScrollEnd.bind(this), 500); | |
this._container.addEventListener('scroll', this._onScroll, {passive: true}); | |
this._$container.on('mouseover', this._onMouseOver.bind(this)) | |
.on('mouseleave', this._onMouseOut.bind(this)) | |
.on('wheel', _.throttle(this._onWheel.bind(this), 100), {passive: true}); | |
w.addEventListener('resize', this._onResize.bind(this)); | |
this._innerHeight = w.innerHeight; | |
this._refreshInviewElements = _.throttle(this._refreshInviewElements.bind(this), 100); | |
this._appendNewItems = throttle.raf(this._appendNewItems.bind(this)); | |
cssUtil.setProps([body,'--inner-height', this._innerHeight]); | |
this._debouncedOnItemClick = _.debounce(this._onItemClick.bind(this), 300); | |
global.debug.$commentList = uq(this._list); | |
global.debug.getCommentPanelItems = () => | |
Array.from(doc.querySelectorAll('.commentListItem')); | |
this.emitResolve('frame-ready'); | |
} | |
async _onModelUpdate(itemList, replaceAll) { | |
if (!this._isFrameReady) { | |
await this.promise('frame-ready'); | |
} | |
this._isFrameReady = true; | |
window.console.time('update commentlistView'); | |
this.addClass('updating'); | |
itemList = Array.isArray(itemList) ? itemList : [itemList]; | |
this.isActive = false; | |
if (replaceAll) { | |
this._scrollTop = this._container ? this._container.scrollTop : 0; | |
} | |
const itemViews = itemList.map((item, i) => | |
new this._ItemView({item: item, index: i, height: CommentListView.ITEM_HEIGHT}) | |
); | |
this._itemViews = itemViews; | |
await cssUtil.setProps([this.body, '--list-height', | |
Math.max(CommentListView.ITEM_HEIGHT * itemViews.length, this._innerHeight) + 100]); | |
if (!this._list) { return; } | |
this._list.textContent = ''; | |
this._inviewItemList.clear(); | |
this._$menu.removeClass('show'); | |
this._refreshInviewElements(); | |
this.hideItemDetail(); | |
window.setTimeout(() => { | |
this.removeClass('updating'); | |
this.emit('update'); | |
}, 100); | |
window.console.timeEnd('update commentlistView'); | |
} | |
_onClick(e) { | |
e.stopPropagation(); | |
global.emitter.emitAsync('hideHover'); | |
const item = e.target.closest('.commentListItem'); | |
if (item) { | |
return this._debouncedOnItemClick(e, item); | |
} | |
} | |
_onItemClick(e, item) { | |
if (e.target.closest('.nicoru-icon')) { | |
item.classList.add('nicotta'); | |
item.dataset.nicoru = item.dataset.nicoru ? (item.dataset.nicoru * 1 + 1) : 1; | |
this.emit('command', 'nicoru', item, item.dataset.itemId); | |
return; | |
} | |
this._$menu | |
.css('transform', `translate(0, ${item.dataset.top}px)`) | |
.attr('data-item-id', item.dataset.itemId) | |
.addClass('show'); | |
} | |
_onMenuClick(e) { | |
const target = e.target.closest('.menuButton'); | |
this._$menu.removeClass('show'); | |
if (!target) { | |
return; | |
} | |
const {itemId} = e.target.closest('.listMenu').dataset; | |
if (!itemId) { | |
return; | |
} | |
const {command} = target.dataset; | |
if (command === 'addUserIdFilter' || command === 'addWordFilter') { | |
Array.from(this._list.querySelectorAll(`.item${itemId}`)) | |
.forEach(e => e.remove()); | |
} | |
this.emit('command', command, null, itemId); | |
} | |
_onItemDetailClick(e) { | |
const target = e.target.closest('.command'); | |
if (!target) { | |
return; | |
} | |
const itemId = this._$itemDetail.attr('data-item-id'); | |
if (!itemId) { | |
return; | |
} | |
const {command, param} = target.dataset; | |
if (command === 'hideItemDetail') { | |
return this.hideItemDetail(); | |
} | |
if (command === 'reloadComment') { | |
this.hideItemDetail(); | |
} | |
this.emit('command', command, param, itemId); | |
} | |
_onDblClick(e) { | |
e.stopPropagation(); | |
const item = e.target.closest('.commentListItem'); | |
if (!item) { | |
return; | |
} | |
e.preventDefault(); | |
const itemId = item.dataset.itemId; | |
this.emit('command', 'select', null, itemId); | |
} | |
_onMouseMove() { | |
this.isActive = true; | |
this.addClass('is-active'); | |
} | |
_onMouseOver() { | |
this.isActive = true; | |
this.addClass('is-active'); | |
} | |
_onWheel() { | |
this.isActive = true; | |
this.addClass('is-active'); | |
} | |
_onMouseOut() { | |
this.isActive = false; | |
this.removeClass('is-active'); | |
} | |
_onResize() { | |
this._innerHeight = this.contentWindow.innerHeight; | |
cssUtil.setProps([this.body, '--inner-height', this._innerHeight]); | |
this._refreshInviewElements(); | |
} | |
_onScroll(e) { | |
if (!this.hasClass('is-scrolling')) { | |
this.addClass('is-scrolling'); | |
} | |
this._onScrolling(); | |
this._onScrollEnd(); | |
} | |
_onScrolling() { | |
this.syncScrollTop(); | |
this._refreshInviewElements(); | |
} | |
_onScrollEnd() { | |
this.removeClass('is-scrolling'); | |
} | |
_refreshInviewElements() { | |
if (!this._list || !this.frameLayer.isVisible) { | |
return; | |
} | |
const itemHeight = CommentListView.ITEM_HEIGHT; | |
const scrollTop = this._scrollTop; | |
const innerHeight = this._innerHeight; | |
const windowBottom = scrollTop + innerHeight; | |
const itemViews = this._itemViews || []; | |
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 10); | |
const endIndex = Math.min(itemViews.length, Math.floor(windowBottom / itemHeight) + 10); | |
let changed = 0; | |
const newItems = this.newItems, inviewItemList = this._inviewItemList; | |
for (let i = startIndex; i < endIndex; i++) { | |
if (inviewItemList.has(i) || !itemViews[i]) { | |
continue; | |
} | |
changed++; | |
newItems.push(itemViews[i].viewElement); | |
inviewItemList.set(i, itemViews[i]); | |
} | |
const removedItems = this.removedItems; | |
for (const i of inviewItemList.keys()) { | |
if (i >= startIndex && i <= endIndex) { | |
continue; | |
} | |
changed++; | |
removedItems.push(inviewItemList.get(i).viewElement); | |
inviewItemList.delete(i); | |
} | |
if (changed < 1) { | |
return; | |
} | |
this._appendNewItems(); | |
} | |
_appendNewItems() { | |
if (this.removedItems.length) { | |
for (const e of this.removedItems) { e.remove(); } | |
this.removedItems.length = 0; | |
} | |
if (!this.newItems.length) { | |
return; | |
} | |
const f = this._appendFragment = this._appendFragment || document.createDocumentFragment(); | |
f.append(...this.newItems); | |
this._list.append(f); | |
for (const e of this.newItems) { e.style.contentVisibility = 'visible'; } | |
this.newItems.length = 0; | |
} | |
_updatePerspective() { | |
const keys = Object.keys(this._inviewItemList); | |
let avr = 0; | |
if (!this._inviewItemList.size) { | |
avr = 50; | |
} else { | |
let min = 0xffff; | |
let max = -0xffff; | |
keys.forEach(key => { | |
let item = this._inviewItemList.get(key); | |
min = Math.min(min, item.time3dp); | |
max = Math.max(max, item.time3dp); | |
avr += item.time3dp; | |
}); | |
avr = avr / keys.length * 100 + 50; //max * 100; //(min + max) / 2 + 10; //50 + avr / keys.length; | |
} | |
this._list.style.transform = `translateZ(-${avr}px)`; | |
} | |
addClass(className) { | |
this.classList && this.classList.add(className); | |
} | |
removeClass(className) { | |
this.classList && this.classList.remove(className); | |
} | |
toggleClass(className, v) { | |
this.classList && this.classList.toggle(className, v); | |
} | |
hasClass(className) { | |
return this.classList.contains(className); | |
} | |
find(query) { | |
return this.document.querySelectorAll(query); | |
} | |
syncScrollTop() { | |
if (!this.contentWindow || !this.frameLayer.isVisible) { | |
return; | |
} | |
if (this.isActive) { | |
this._scrollTop = this._container.scrollTop; | |
} | |
} | |
setScrollTop(v) { | |
if (!this.contentWindow) { | |
return; | |
} | |
this._scrollTop = v; | |
if (!this.frameLayer.isVisible) { | |
return; | |
} | |
this._container.scrollTop = v; | |
} | |
setCurrentPoint(sec, idx, isAutoScroll) { | |
if (!this.contentWindow || !this._itemViews || !this.frameLayer.isVisible) { | |
return; | |
} | |
const innerHeight = this._innerHeight; | |
const itemViews = this._itemViews; | |
const len = itemViews.length; | |
const view = itemViews[idx]; | |
if (len < 1 || !view) { | |
return; | |
} | |
const itemHeight = CommentListView.ITEM_HEIGHT; | |
const top = Math.max(0, view.top - innerHeight + itemHeight); | |
this.timeScrollTop = top; | |
this.isAutoScroll = isAutoScroll; | |
if (!this.isActive && isAutoScroll) { | |
this.setScrollTop(top); | |
} | |
} | |
showItemDetail(item) { | |
const $d = this._$itemDetail; | |
$d.attr('data-item-id', item.itemId); | |
$d.find('.resNo').text(item.no).end() | |
.find('.vpos').text(item.timePos).end() | |
.find('.time').text(item.formattedDate).end() | |
.find('.userId').text(item.userId).end() | |
.find('.cmd').text(item.cmd).end() | |
.find('.text').text(item.text).end() | |
.addClass('show'); | |
global.debug.$itemDetail = $d; | |
} | |
hideItemDetail() { | |
this._$itemDetail.removeClass('show'); | |
} | |
} | |
CommentListView.ITEM_HEIGHT = 40; | |
CommentListView.__css__ = ''; | |
CommentListView.__tpl__ = (` | |
<!DOCTYPE html> | |
<html lang="ja"> | |
<head> | |
<meta charset="utf-8"> | |
<title>CommentList</title> | |
<style type="text/css"> | |
${CONSTANT.BASE_CSS_VARS} | |
body { | |
user-select: none; | |
margin: 0; | |
padding: 0; | |
overflow: hidden; | |
} | |
body .is-debug { | |
perspective: 100px; | |
perspective-origin: left top; | |
transition: perspective 0.2s ease; | |
} | |
body.is-scrolling #listContainerInner *{ | |
pointer-events: none; | |
} | |
.is-firefox .virtualScrollBarContainer { | |
content: ''; | |
position: fixed; | |
top: 0; | |
right: 0; | |
width: 16px; | |
height: 100vh; | |
background: rgba(0, 0, 0, 0.6); | |
z-index: 100; | |
contain: strict; | |
pointer-events: none; | |
} | |
#listContainer { | |
position: absolute; | |
top: -1px; | |
left:0; | |
margin: 0; | |
padding: 0; | |
width: 100vw; | |
height: 100vh; | |
overflow-y: scroll; | |
overflow-x: hidden; | |
overscroll-behavior: none; | |
will-change: transform; | |
scrollbar-width: 16px; | |
scrollbar-color: #039393; | |
} | |
.is-firefox #listContainer { | |
will-change: auto; | |
} | |
#listContainerInner { | |
height: calc(var(--list-height) * 1px); | |
min-height: calc(100vh + 100px); | |
} | |
.is-debug #listContainerInner { | |
transform-style: preserve-3d; | |
transform: translateZ(-50px); | |
transition: transform 0.2s; | |
} | |
#listContainerInner:empty::after { | |
content: 'コメントは空です'; | |
color: #666; | |
display: inline-block; | |
text-align: center; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
pointer-events: none; | |
} | |
.is-guest .forMember { | |
display: none !important; | |
} | |
.itemDetailContainer { | |
position: fixed; | |
display: block; | |
top: 50%; | |
left: 50%; | |
line-height: normal; | |
min-width: 280px; | |
max-height: 100%; | |
overflow-y: scroll; | |
overscroll-behavior: none; | |
font-size: 14px; | |
transform: translate(-50%, -50%); | |
opacity: 0; | |
pointer-events: none; | |
z-index: 100; | |
border: 2px solid #fc9; | |
background-color: rgba(255, 255, 232, 0.9); | |
box-shadow: 4px 4px 0 rgba(99, 99, 66, 0.8); | |
transition: opacity 0.2s; | |
} | |
.itemDetailContainer.show { | |
opacity: 1; | |
pointer-events: auto; | |
} | |
.itemDetailContainer>* { | |
} | |
.itemDetailContainer * { | |
word-break: break-all; | |
} | |
.itemDetailContainer .reloadComment { | |
display: inline-block; | |
padding: 0 4px; | |
cursor: pointer; | |
transform: scale(1.4); | |
transition: transform 0.1s; | |
} | |
.itemDetailContainer .reloadComment:hover { | |
transform: scale(1.8); | |
} | |
.itemDetailContainer .reloadComment:active { | |
transform: scale(1.2); | |
transition: none; | |
} | |
.itemDetailContainer .resNo, | |
.itemDetailContainer .vpos, | |
.itemDetailContainer .time, | |
.itemDetailContainer .userId, | |
.itemDetailContainer .cmd { | |
font-size: 12px; | |
} | |
.itemDetailContainer .time { | |
cursor: pointer; | |
color: #339; | |
} | |
.itemDetailContainer .time:hover { | |
text-decoration: underline; | |
} | |
.itemDetailContainer .time:hover:after { | |
position: absolute; | |
content: '${'\\00231A'} 過去ログ'; | |
right: 16px; | |
text-decoration: none; | |
transform: scale(1.4); | |
} | |
.itemDetailContainer .resNo:before, | |
.itemDetailContainer .vpos:before, | |
.itemDetailContainer .time:before, | |
.itemDetailContainer .userId:before, | |
.itemDetailContainer .cmd:before { | |
display: inline-block; | |
min-width: 50px; | |
} | |
.itemDetailContainer .resNo:before { | |
content: 'no'; | |
} | |
.itemDetailContainer .vpos:before { | |
content: 'pos'; | |
} | |
.itemDetailContainer .time:before { | |
content: 'date'; | |
} | |
.itemDetailContainer .userId:before { | |
content: 'user'; | |
} | |
.itemDetailContainer .cmd:before { | |
content: 'cmd'; | |
} | |
.itemDetailContainer .text { | |
border: 1px inset #ccc; | |
padding: 8px; | |
margin: 4px 8px; | |
} | |
.itemDetailContainer .close { | |
border: 2px solid #666; | |
width: 50%; | |
cursor: pointer; | |
text-align: center; | |
margin: auto; | |
user-select: none; | |
} | |
.is-firefox .timeBar { display: none !important; } | |
/*.timeBar { | |
position: fixed; | |
visibility: hidden; | |
z-index: 110; | |
right: 0; | |
top: 1px; | |
width: 14px; | |
--height-pp: calc(1px * var(--inner-height) * var(--inner-height) / var(--list-height)); | |
--trans-y-pp: calc((1px * var(--inner-height) - var(--height-pp)) * var(--time-scroll-top) / var(--list-height)); | |
min-height: 10px; | |
height: var(--height-pp); | |
max-height: 100vh; | |
transform: translateY(var(--trans-y-pp)); | |
pointer-events: none; | |
will-change: transform; | |
border: 1px dashed #e12885; | |
opacity: 0.8; | |
} | |
.timeBar::after { | |
width: calc(100% + 6px); | |
height: calc(100% + 6px); | |
left: -3px; | |
top: -3px; | |
content: ''; | |
position: absolute; | |
border: 2px solid #2b2b2b; | |
outline: 2px solid #2b2b2b; | |
outline-offset: -5px; | |
box-sizing: border-box; | |
}*/ | |
body:hover .timeBar { | |
visibility: visible; | |
} | |
.virtualScrollBar { | |
display: none; | |
} | |
/* | |
.is-firefox .virtualScrollBar { | |
display: inline-block; | |
position: fixed; | |
z-index: 100; | |
right: 0; | |
top: 0px; | |
width: 16px; | |
--height-pp: calc( 1px * var(--inner-height) * var(--inner-height) / var(--list-height) ); | |
--trans-y-pp: calc( 1px * var(--inner-height) * var(--scroll-top) / var(--list-height)); | |
height: var(--height-pp); | |
background: #039393; | |
max-height: 100vh; | |
transform: translateY(var(--trans-y-pp)); | |
pointer-events: none; | |
will-change: transform; | |
z-index: 110; | |
} | |
*/ | |
</style> | |
<style id="listItemStyle">%CSS%</style> | |
<body class="zenzaRoot"> | |
<div class="itemDetailContainer"> | |
<div class="resNo"></div> | |
<div class="vpos"></div> | |
<div class="time command" data-command="reloadComment"></div> | |
<div class="userId"></div> | |
<div class="cmd"></div> | |
<div class="text"></div> | |
<div class="command close" data-command="hideItemDetail">O K</div> | |
</div> | |
<div class="virtualScrollBarContainer"><div class="virtualScrollBar"></div></div><div class="timeBar"></div> | |
<div id="listContainer"> | |
<div class="listMenu"> | |
<span class="menuButton itemDetailRequest" | |
data-command="itemDetailRequest" title="詳細">?</span> | |
<span class="menuButton clipBoard" data-command="clipBoard" title="クリップボードにコピー">copy</span> | |
<span class="menuButton addUserIdFilter" data-command="addUserIdFilter" title="NGユーザー">NGuser</span> | |
<span class="menuButton addWordFilter" data-command="addWordFilter" title="NGワード">NGword</span> | |
</div> | |
<div id="listContainerInner"></div> | |
</div> | |
</body> | |
</html> | |
`).trim(); | |
const CommentListItemView = (() => { | |
const CSS = (` | |
* { | |
box-sizing: border-box; | |
} | |
body { | |
background: #000; | |
margin: 0; | |
padding: 0; | |
overflow: hidden; | |
line-height: 0; | |
} | |
${CONSTANT.SCROLLBAR_CSS} | |
.listMenu { | |
position: absolute; | |
display: block; | |
} | |
.listMenu.show { | |
display: block; | |
width: 100%; | |
left: 0; | |
z-index: 100; | |
} | |
.listMenu .menuButton { | |
display: inline-block; | |
position: absolute; | |
font-size: 13px; | |
line-height: 20px; | |
border: 1px solid #666; | |
color: #fff; | |
background: #666; | |
cursor: pointer; | |
top: 0; | |
text-align: center; | |
} | |
.listMenu .menuButton:hover { | |
border: 1px solid #ccc; | |
box-shadow: 2px 2px 2px #333; | |
} | |
.listMenu .menuButton:active { | |
box-shadow: none; | |
transform: translate(0, 1px); | |
} | |
.listMenu .itemDetailRequest { | |
right: 176px; | |
width: auto; | |
padding: 0 8px; | |
} | |
.listMenu .clipBoard { | |
right: 120px; | |
width: 48px; | |
} | |
.listMenu .addWordFilter { | |
right: 64px; | |
width: 48px; | |
} | |
.listMenu .addUserIdFilter { | |
right: 8px; | |
width: 48px; | |
} | |
.commentListItem { | |
position: absolute; | |
display: inline-block; | |
will-change: transform; | |
width: 100%; | |
height: 40px; | |
line-height: 20px; | |
font-size: 20px; | |
white-space: nowrap; | |
margin: 0; | |
padding: 0; | |
background: #222; | |
z-index: 50; | |
contain: strict; | |
} | |
.is-firefox .commentListItem { | |
contain: layout !important; | |
width: calc(100vw - 16px); | |
will-change: auto; | |
} | |
.is-active .commentListItem { | |
pointer-events: auto; | |
} | |
.commentListItem * { | |
cursor: default; | |
} | |
.commentListItem.odd { | |
background: #333; | |
} | |
.commentListItem[data-nicoru] { | |
background: #332; | |
} | |
.commentListItem.odd[data-nicoru] { | |
background: #443; | |
} | |
.commentListItem[data-nicoru]:hover::before { | |
position: absolute; | |
content: attr(data-nicoru); | |
color: #ccc; | |
font-size: 12px; | |
left: 80px; | |
} | |
.commentListItem .nicoru-icon { | |
position: absolute; | |
pointer-events: none; | |
display: inline-block; | |
cursor: pointer; | |
visibility: hidden; | |
transition: transform 0.2s linear, filter 0.2s; | |
transform-origin: center; | |
left: 50px; | |
top: -2px; | |
width: 24px; | |
height: 24px; | |
contain: strict; | |
} | |
.commentListItem:hover .nicoru-icon { | |
visibility: visible; | |
} | |
.is-premium .commentListItem:hover .nicoru-icon { | |
pointer-events: auto; | |
} | |
.commentListItem.nicotta .nicoru-icon { | |
visibility: visible; | |
transform: rotate(270deg); | |
filter: drop-shadow(0px 0px 6px gold); | |
pointer-events: none; | |
} | |
.commentListItem.updating { | |
opacity: 0.5; | |
cursor: wait; | |
} | |
.commentListItem .info { | |
display: flex; | |
justify-content: space-between; | |
width: 100%; | |
font-size: 14px; | |
height: 20px; | |
overflow: hidden; | |
white-space: nowrap; | |
text-overflow: ellipsis; | |
color: #888; | |
margin: 0; | |
padding: 0 8px 0; | |
} | |
.commentListItem[data-valhalla="1"] .info { | |
color: #f88; | |
} | |
.commentListItem .timepos { | |
display: inline-block; | |
width: 100px; | |
} | |
.commentListItem .text { | |
display: block; | |
font-size: 16px; | |
height: 20px; | |
overflow: hidden; | |
white-space: nowrap; | |
text-overflow: ellipsis; | |
color: #ccc; | |
margin: 0; | |
padding: 0 8px; | |
font-family: '游ゴシック', 'Yu Gothic', 'YuGothic', arial, 'Menlo'; | |
font-feature-settings: "palt" 1; | |
} | |
.commentListItem[data-valhalla="1"] .text { | |
color: red; | |
font-weight: bold; | |
} | |
.is-active .commentListItem:hover { | |
overflow-x: hidden; | |
overflow-y: visible; | |
z-index: 60; | |
height: auto; | |
box-shadow: 2px 2px 2px #000, 2px -2px 2px #000; | |
contain: layout style paint; | |
} | |
.is-active .commentListItem:hover .text { | |
white-space: normal; | |
word-break: break-all; | |
height: auto; | |
} | |
.commentListItem.fork1 .timepos { | |
text-shadow: 1px 1px 0 #008800, -1px -1px 0 #008800 !important; | |
} | |
.commentListItem.fork2 .timepos { | |
opacity: 0.6; | |
} | |
.commentListItem.fork1 .text { | |
font-weight: bolder; | |
} | |
.commentListItem.subThread { | |
opacity: 0.6; | |
} | |
.commentListItem.is-active { | |
outline: dashed 2px #ff8; | |
outline-offset: 4px; | |
} | |
.font-gothic .text {font-family: "游ゴシック", "Yu Gothic", 'YuGothic', "MS ゴシック", "IPAMonaPGothic", sans-serif, Arial, Menlo;} | |
.font-mincho .text {font-family: "游明朝体", "Yu Mincho", 'YuMincho', Simsun, Osaka-mono, "Osaka−等幅", "MS 明朝", "MS ゴシック", "モトヤLシーダ3等幅", 'Hiragino Mincho ProN', monospace;} | |
.font-defont .text {font-family: 'Yu Gothic', 'YuGothic', "MS ゴシック", "MS Gothic", "Meiryo", "ヒラギノ角ゴ", "IPAMonaPGothic", sans-serif, monospace, Menlo; } | |
/* | |
.commentListItem .progress-negi { | |
position: absolute; | |
width: 2px; | |
height: 100%; | |
bottom: 0; | |
right: 0; | |
pointer-events: none; | |
background: #888; | |
will-change: transform; | |
animation-duration: var(--duration); | |
animation-delay: calc(var(--vpos-time) - var(--current-time) - 1s); | |
animation-name: negi-moving; | |
animation-timing-function: linear; | |
animation-fill-mode: forwards; | |
animation-play-state: paused !important; | |
contain: paint layout style size; | |
} | |
@keyframes negi-moving { | |
0% { background: #ebe194;} | |
50% { background: #fff; } | |
80% { background: #fff; } | |
100% { background: #039393; } | |
} | |
*/ | |
`).trim(); | |
const TPL = (` | |
<div class="commentListItem" style="position: absolute;"> | |
<img src="${NICORU}" class="nicoru-icon" data-command="nicoru" title="Nicorü"> | |
<p class="info"> | |
<span class="timepos"></span> <span class="date"></span> | |
</p> | |
<p class="text"></p> | |
<span class="progress-negi" style="position: absolute; will-change: transform; contain: strict;"></span> | |
</div> | |
`).trim(); | |
let counter = 0; | |
let template; | |
class CommentListItemView { | |
static get template() { | |
if (!template) { | |
const t = document.createElement('template'); | |
t.id = 'CommentListItemView-template' + Date.now(); | |
t.innerHTML = TPL; | |
template = { | |
t, | |
clone: () => { | |
return document.importNode(t.content, true).firstChild; | |
}, | |
commentListItem: t.content.querySelector('.commentListItem'), | |
timepos: t.content.querySelector('.timepos'), | |
date: t.content.querySelector('.date'), | |
text: t.content.querySelector('.text') | |
}; | |
} | |
return template; | |
} | |
constructor(params) { | |
this.initialize(params); | |
} | |
initialize(params) { | |
this._item = params.item; | |
this._index = params.index; | |
this._height = params.height; | |
this._id = counter++; | |
} | |
build() { | |
const template = this.constructor.template; | |
const {commentListItem, timepos, date, text} = template; | |
const item = this._item; | |
const oden = (this._index % 2 === 0) ? 'even' : 'odd'; | |
const time3dp = Math.round(this._item.time3dp * 100); | |
const formattedDate = item.formattedDate; | |
commentListItem.id = this.domId; | |
const font = item.fontCommand || 'default'; | |
commentListItem.className = | |
`commentListItem no${item.no} item${this._id} ${oden} fork${item.fork} font-${font} ${item.isSubThread ? 'subThread' : ''}`; | |
commentListItem.classList.toggle('nicotta', item.nicotta); | |
commentListItem.style.cssText = `top: ${this.top}px; content-visibility: hidden;`; | |
/*--duration: ${item.duration}s; | |
--vpos-time: ${item.vpos / 100}s;*/ | |
Object.assign(commentListItem.dataset, { | |
itemId: item.itemId, | |
no: item.no, | |
uniqNo: item.uniqNo, | |
vpos: item.vpos, | |
top: this.top, | |
thread: item.threadId, | |
title: `${item.no}: ${formattedDate} ID:${item.userId}\n${item.text}`, | |
time3dp, | |
valhalla: item.valhalla, | |
}); | |
item.nicoru > 0 ? | |
(commentListItem.dataset.nicoru = item.nicoru) : | |
(delete commentListItem.dataset.nicoru); | |
timepos.textContent = item.timePos; | |
date.textContent = formattedDate; | |
text.textContent = item.text.trim(); | |
const color = item.color; | |
text.style.textShadow = color ? `0px 0px 2px ${color}` : ''; | |
this._view = template.clone(); | |
} | |
get viewElement() { | |
if (!this._view) { | |
this.build(); | |
} | |
return this._view; | |
} | |
get itemId() { | |
return this._item.itemId; | |
} | |
get domId() { | |
return `item${this._item.itemId}`; | |
} | |
get top() { | |
return this._index * this._height; | |
} | |
remove() { | |
if (!this._view) { | |
return; | |
} | |
this._view.remove(); | |
} | |
toString() { | |
return this.viewElement.outerHTML; | |
} | |
get time3dp() { | |
return this._item.time3dp; | |
} | |
get time3d() { | |
return this._item.time3d; | |
} | |
get nicotta() { | |
return this._item.nicotta; | |
} | |
set nicotta(v) { | |
this._item.nicotta = v; | |
this._view.classList.toggle('nicotta', v); | |
} | |
get nicoru() { | |
return this._item.nicoru; | |
} | |
set nicoru(v) { | |
this._item.nicoru = v; | |
v > 0 ? | |
(this._view.dataset.nicoru = v) : (delete this._view.dataset.nicoru); | |
} | |
} | |
CommentListItemView.TPL = TPL; | |
CommentListItemView.CSS = CSS; | |
return CommentListItemView; | |
})(); | |
class CommentListItem { | |
constructor(nicoChat) { | |
this.nicoChat = nicoChat; | |
this._itemId = CommentListItem._itemId++; | |
this._vpos = nicoChat.vpos; | |
this._text = nicoChat.text; | |
this._escapedText = textUtil.escapeHtml(this._text); | |
this._userId = nicoChat.userId; | |
this._date = nicoChat.date; | |
this._fork = nicoChat.fork; | |
this._no = nicoChat.no; | |
this._color = nicoChat.color; | |
this._fontCommand = nicoChat.fontCommand; | |
this._isSubThread = nicoChat.isSubThread; | |
this._formattedDate = textUtil.dateToString(this._date * 1000); | |
this._timePos = textUtil.secToTime(this._vpos / 100); | |
} | |
get itemId() {return this._itemId;} | |
get vpos() {return this._vpos;} | |
get timePos() {return this._timePos;} | |
get cmd() {return this.nicoChat.cmd;} | |
get text() {return this._text;} | |
get escapedText() {return this._escapedText;} | |
get userId() {return this._userId;} | |
get color() {return this._color;} | |
get date() {return this._date;} | |
get time() {return this._date * 1000;} | |
get formattedDate() {return this._formattedDate;} | |
get fork() {return this._fork;} | |
get no() {return this._no;} | |
get uniqNo() {return this.nicoChat.uniqNo;} | |
get fontCommand() {return this._fontCommand;} | |
get isSubThread() {return this._isSubThread;} | |
get threadId() {return this.nicoChat.threadId;} | |
get time3d() {return this.nicoChat.time3d;} | |
get time3dp() {return this.nicoChat.time3dp;} | |
get nicoru() {return this.nicoChat.nicoru;} | |
set nicoru(v) { this.nicoChat.nicoru = v;} | |
get duration() {return this.nicoChat.duration;} | |
get valhalla() {return this.nicoChat.valhalla;} | |
get nicotta() { return this.nicoChat.nicotta;} | |
set nicotta(v) { this.nicoChat.nicotta = v; } | |
} | |
CommentListItem._itemId = 0; | |
class CommentPanelView extends Emitter { | |
constructor(params) { | |
super(); | |
this.$container = params.$container; | |
this.model = params.model; | |
this.commentPanel = params.commentPanel; | |
css.addStyle(CommentPanelView.__css__); | |
const $view = this.$view = uq.html(CommentPanelView.__tpl__); | |
this.$container.append($view); | |
const $menu = this._$menu = this.$view.find('.commentPanel-menu'); | |
global.debug.commentPanelView = this; | |
const listView = this._listView = new CommentListView({ | |
container: this.$view.find('.commentPanel-frame')[0], | |
model: this.model, | |
className: 'commentList', | |
builder: CommentListItemView, | |
itemCss: CommentListItemView.__css__ | |
}); | |
listView.on('command', this._onCommand.bind(this)); | |
this._timeMachineView = new TimeMachineView({ | |
parentNode: document.querySelector('.timeMachineContainer') | |
}); | |
this._timeMachineView.on('command', this._onCommand.bind(this)); | |
this.commentPanel.on('threadInfo', | |
_.debounce(this._onThreadInfo.bind(this), 100)); | |
this.commentPanel.on('update', | |
_.debounce(this._onCommentPanelStatusUpdate.bind(this), 100)); | |
this.commentPanel.on('itemDetailResp', | |
_.debounce(item => listView.showItemDetail(item), 100)); | |
this._onCommentPanelStatusUpdate(); | |
this.model.on('currentTimeUpdate', this._onModelCurrentTimeUpdate.bind(this)); | |
this.$view.on('click', this._onCommentListCommandClick.bind(this)); | |
global.emitter.on('hideHover', () => $menu.removeClass('show')); | |
} | |
toggleClass(className, v) { | |
this.$view.raf.toggleClass(className, v); | |
} | |
_onModelCurrentTimeUpdate(sec, viewIndex) { | |
if (!this.$view){ | |
return; | |
} | |
this._lastCurrentTime = sec; | |
this._listView.setCurrentPoint(sec, viewIndex, this.commentPanel.isAutoScroll); | |
} | |
_onCommand(command, param, itemId) { | |
switch (command) { | |
case 'nicoru': | |
param.nicotta = true; | |
this.emit('command', command, param, itemId); | |
break; | |
default: | |
this.emit('command', command, param, itemId); | |
break; | |
} | |
} | |
_onCommentListCommandClick(e) { | |
const target = e.target.closest('[data-command]'); | |
if (!target) { return; } | |
const {command, param} = target.dataset; | |
e.stopPropagation(); | |
if (!command) { | |
return; | |
} | |
const $view = this.$view; | |
const setUpdating = () => { | |
document.activeElement.blur(); | |
$view.raf.addClass('updating'); | |
window.setTimeout(() => $view.removeClass('updating'), 1000); | |
}; | |
switch (command) { | |
case 'sortBy': | |
setUpdating(); | |
this.emit('command', command, param); | |
break; | |
case 'reloadComment': | |
setUpdating(); | |
this.emit('command', command, param); | |
break; | |
default: | |
this.emit('command', command, param); | |
} | |
global.emitter.emitAsync('hideHover'); | |
} | |
_onThreadInfo(threadInfo) { | |
this._timeMachineView.update(threadInfo); | |
} | |
_onCommentPanelStatusUpdate() { | |
const commentPanel = this.commentPanel; | |
const $view = this.$view.raf.toggleClass('autoScroll', commentPanel.isAutoScroll); | |
const langClass = `lang-${commentPanel.getLanguage()}`; | |
if (!$view.hasClass(langClass)) { | |
$view.raf.removeClass('lang-ja_JP lang-en_US lang-zh_TW').addClass(langClass); | |
} | |
} | |
} | |
CommentPanelView.__css__ = ` | |
:root { | |
--zenza-comment-panel-header-height: 64px; | |
} | |
.commentPanel-container { | |
height: 100%; | |
overflow: hidden; | |
user-select: none; | |
} | |
.commentPanel-header { | |
height: var(--zenza-comment-panel-header-height); | |
border-bottom: 1px solid #000; | |
background: #333; | |
color: #ccc; | |
} | |
.commentPanel-menu-button { | |
display: inline-block; | |
cursor: pointer; | |
border: 1px solid #333; | |
padding: 0px 4px; | |
margin: 0 4px; | |
background: #666; | |
font-size: 16px; | |
line-height: 28px; | |
white-space: nowrap; | |
} | |
.commentPanel-menu-button:hover { | |
border: 1px outset; | |
} | |
.commentPanel-menu-button:active { | |
border: 1px inset; | |
} | |
.commentPanel-menu-button .commentPanel-menu-icon { | |
font-size: 24px; | |
line-height: 28px; | |
} | |
.commentPanel-container.autoScroll .autoScroll { | |
text-shadow: 0 0 6px #f99; | |
color: #ff9; | |
} | |
.commentPanel-frame { | |
height: calc(100% - var(--zenza-comment-panel-header-height)); | |
transition: opacity 0.3s; | |
} | |
.updating .commentPanel-frame, | |
.shuffle .commentPanel-frame { | |
opacity: 0; | |
} | |
.commentPanel-menu-toggle { | |
position: absolute; | |
right: 8px; | |
display: inline-block; | |
font-size: 14px; | |
line-height: 32px; | |
cursor: pointer; | |
outline: none; | |
} | |
.commentPanel-menu-toggle:focus-within { | |
pointer-events: none; | |
} | |
.commentPanel-menu-toggle:focus-within .zenzaPopupMenu { | |
pointer-events: auto; | |
visibility: visible; | |
opacity: 0.99; | |
pointer-events: auto; | |
transition: opacity 0.3s; | |
} | |
.commentPanel-menu { | |
position: absolute; | |
right: 0px; | |
top: 24px; | |
min-width: 150px; | |
} | |
.commentPanel-menu li { | |
line-height: 20px; | |
} | |
.commentPanel-container.lang-ja_JP .commentPanel-command[data-param=ja_JP], | |
.commentPanel-container.lang-en_US .commentPanel-command[data-param=en_US], | |
.commentPanel-container.lang-zh_TW .commentPanel-command[data-param=zh_TW] { | |
font-weight: bolder; | |
color: #ff9; | |
} | |
`.trim(); | |
CommentPanelView.__tpl__ = (` | |
<div class="commentPanel-container"> | |
<div class="commentPanel-header"> | |
<label class="commentPanel-menu-button autoScroll commentPanel-command" | |
data-command="toggleScroll"><icon class="commentPanel-menu-icon">⬇️</icon> 自動スクロール</label> | |
<div class="commentPanel-command commentPanel-menu-toggle" tabindex="-1"> | |
▼ メニュー | |
<div class="zenzaPopupMenu commentPanel-menu"> | |
<div class="listInner"> | |
<ul> | |
<li class="commentPanel-command" data-command="sortBy" data-param="vpos"> | |
コメント位置順に並べる | |
</li> | |
<li class="commentPanel-command" data-command="sortBy" data-param="date:desc"> | |
コメントの新しい順に並べる | |
</li> | |
<li class="commentPanel-command" data-command="sortBy" data-param="nicoru:desc"> | |
ニコる数で並べる | |
</li> | |
<hr class="separator"> | |
<li class="commentPanel-command" data-command="update-commentLanguage" data-param="ja_JP"> | |
日本語 | |
</li> | |
<li class="commentPanel-command" data-command="update-commentLanguage" data-param="en_US"> | |
English | |
</li> | |
<li class="commentPanel-command" data-command="update-commentLanguage" data-param="zh_TW"> | |
中文 | |
</li> | |
</ul> | |
</div> | |
</div> | |
</div> | |
<div class="timeMachineContainer"></div> | |
</div> | |
<div class="commentPanel-frame"></div> | |
</div> | |
`).trim(); | |
class CommentPanel extends Emitter { | |
constructor(params) { | |
super(); | |
this._thumbInfoLoader = params.loader || global.api.ThumbInfoLoader; | |
this._$container = params.$container; | |
const player = this._player = params.player; | |
this._autoScroll = _.isBoolean(params.autoScroll) ? params.autoScroll : true; | |
this._model = new CommentListModel({}); | |
this._language = params.language || 'ja_JP'; | |
player.on('commentParsed', _.debounce(this._onCommentParsed.bind(this), 500)); | |
player.on('commentChange', _.debounce(this._onCommentChange.bind(this), 500)); | |
player.on('commentReady', _.debounce(this._onCommentReady.bind(this), 500)); | |
player.on('open', this._onPlayerOpen.bind(this)); | |
player.on('close', this._onPlayerClose.bind(this)); | |
global.debug.commentPanel = this; | |
} | |
_initializeView() { | |
if (this._view) { | |
return; | |
} | |
this._view = new CommentPanelView({ | |
$container: this._$container, | |
model: this._model, | |
commentPanel: this, | |
builder: CommentListItemView, | |
itemCss: CommentListItemView.__css__ | |
}); | |
this._view.on('command', this._onCommand.bind(this)); | |
} | |
startTimer() { | |
this.stopTimer(); | |
this._timer = window.setInterval(this._onTimer.bind(this), 200); | |
} | |
stopTimer() { | |
if (this._timer) { | |
window.clearInterval(this._timer); | |
this._timer = null; | |
} | |
} | |
_onTimer() { | |
if (this._autoScroll) { | |
this.currentTime=this._player.currentTime; | |
} | |
} | |
_onCommand(command, param, itemId) { | |
let item; | |
if (itemId) { | |
item = this._model.findByItemId(itemId); | |
} | |
switch (command) { | |
case 'toggleScroll': | |
this.toggleScroll(); | |
break; | |
case 'sortBy': { | |
const tmp = param.split(':'); | |
this.sortBy(tmp[0], tmp[1] === 'desc'); | |
break;} | |
case 'select':{ | |
const vpos = item.vpos; | |
this.emit('command', 'seek', vpos / 100); | |
break;} | |
case 'clipBoard': | |
Clipboard.copyText(item.text); | |
this.emit('command', 'notify', 'クリップボードにコピーしました'); | |
break; | |
case 'addUserIdFilter': | |
this._model.removeItem(item); | |
this.emit('command', command, item.userId); | |
break; | |
case 'addWordFilter': | |
this._model.removeItem(item); | |
this.emit('command', command, item.text); | |
break; | |
case 'reloadComment': | |
if (item) { | |
param = {}; | |
const dt = new Date(item.time); | |
this.emit('command', 'notify', item.formattedDate + '頃のログ'); | |
param.when = Math.floor(dt.getTime() / 1000); | |
} | |
this.emit('command', command, param); | |
break; | |
case 'itemDetailRequest': | |
if (item) { | |
this.emit('itemDetailResp', item); | |
} | |
break; | |
case 'nicoru': | |
item.nicotta = true; | |
item.nicoru += 1; | |
this.emit('command', command, item.nicoChat); | |
break; | |
default: | |
this.emit('command', command, param); | |
} | |
} | |
_onCommentParsed(language) { | |
this.setLanguage(language); | |
this._initializeView(); | |
this.setChatList(this._player.chatList); | |
this.startTimer(); | |
} | |
_onCommentChange(language) { | |
this.setLanguage(language); | |
this._initializeView(); | |
this.setChatList(this._player.chatList); | |
} | |
_onCommentReady(result, threadInfo) { | |
this._threadInfo = threadInfo; | |
this.emit('threadInfo', threadInfo); | |
} | |
_onPlayerOpen() { | |
this._model.clear(); | |
} | |
_onPlayerClose() { | |
this._model.clear(); | |
this.stopTimer(); | |
} | |
setChatList(chatList) { | |
if (!this._model) { | |
return; | |
} | |
this._model.setChatList(chatList); | |
} | |
get isAutoScroll() { | |
return this._autoScroll; | |
} | |
getLanguage() { | |
return this._language || 'ja_JP'; | |
} | |
getThreadInfo() { | |
return this._threadInfo; | |
} | |
setLanguage(lang) { | |
if (lang !== this._language) { | |
this._language = lang; | |
this.emit('update'); | |
} | |
} | |
toggleScroll(v) { | |
if (!_.isBoolean(v)) { | |
this._autoScroll = !this._autoScroll; | |
if (this._autoScroll) { | |
this._model.sortBy('vpos'); | |
} | |
this.emit('update'); | |
return; | |
} | |
if (this._autoScroll !== v) { | |
this._autoScroll = v; | |
if (this._autoScroll) { | |
this._model.sortBy('vpos'); | |
} | |
this.emit('update'); | |
} | |
} | |
sortBy(key, isDesc) { | |
this._model.sortBy(key, isDesc); | |
if (key !== 'vpos') { | |
this.toggleScroll(false); | |
} | |
} | |
set currentTime(sec) { | |
if (!this._view || this._player.currentTab !== 'comment') { | |
return; | |
} | |
this._model.currentTime = sec; | |
} | |
get currentTime() { | |
return this._model.currentTime; | |
} | |
} | |
class TimeMachineView extends BaseViewComponent { | |
constructor({parentNode}) { | |
super({ | |
parentNode, | |
name: 'TimeMachineView', | |
template: '<div class="TimeMachineView"></div>', | |
shadow: TimeMachineView._shadow_, | |
css: '' | |
}); | |
this._bound._onTimer = this._onTimer.bind(this); | |
this._state = { | |
isWaybackMode: false, | |
isSelecting: false, | |
}; | |
this._currentTimestamp = Date.now(); | |
global.debug.timeMachineView = this; | |
window.setInterval(this._bound._onTimer, 3 * 1000); | |
} | |
_initDom(...args) { | |
super._initDom(...args); | |
const v = this._shadow || this._view; | |
Object.assign(this._elm, { | |
time: v.querySelector('.dateTime'), | |
back: v.querySelector('.backToTheFuture'), | |
input: v.querySelector('.dateTimeInput'), | |
submit: v.querySelector('.dateTimeSubmit'), | |
cancel: v.querySelector('.dateTimeCancel') | |
}); | |
this._updateTimestamp(); | |
this._elm.time.addEventListener('click', this._toggle.bind(this)); | |
this._elm.back.addEventListener('mousedown', _.debounce(this._onBack.bind(this), 300)); | |
this._elm.submit.addEventListener('click', this._onSubmit.bind(this)); | |
this._elm.cancel.addEventListener('click', this._onCancel.bind(this)); | |
} | |
update(threadInfo) { | |
this._videoPostTime = threadInfo.threadId * 1000; | |
const isWaybackMode = threadInfo.isWaybackMode; | |
this.setState({isWaybackMode, isSelecting: false}); | |
if (isWaybackMode) { | |
this._currentTimestamp = threadInfo.when * 1000; | |
} else { | |
this._currentTimestamp = Date.now(); | |
} | |
this._updateTimestamp(); | |
} | |
_updateTimestamp() { | |
if (isNaN(this._currentTimestamp)) { | |
return; | |
} | |
this._elm.time.textContent = this._currentTime = this._toDate(this._currentTimestamp); | |
} | |
openSelect() { | |
const input = this._elm.input; | |
const now = this._toTDate(Date.now()); | |
input.setAttribute('max', now); | |
input.setAttribute('value', this._toTDate(this._currentTimestamp)); | |
input.setAttribute('min', this._toTDate(this._videoPostTime)); | |
this.setState({isSelecting: true}); | |
window.setTimeout(() => { | |
input.focus(); | |
}, 0); | |
} | |
closeSelect() { | |
this.setState({isSelecting: false}); | |
} | |
_toggle() { | |
if (this._state.isSelecting) { | |
this.closeSelect(); | |
} else { | |
this.openSelect(); | |
} | |
} | |
_onTimer() { | |
if (this._state.isWaybackMode) { | |
return; | |
} | |
let now = Date.now(); | |
let str = this._toDate(now); | |
if (this._currentTime === str) { | |
return; | |
} | |
this._currentTimestamp = now; | |
this._updateTimestamp(); | |
} | |
_padTime(time) { | |
const pad = v => v.toString().padStart(2, '0'); | |
const dt = new Date(time); | |
return { | |
yyyy: dt.getFullYear(), | |
mm: pad(dt.getMonth() + 1), | |
dd: pad(dt.getDate()), | |
h: pad(dt.getHours()), | |
m: pad(dt.getMinutes()), | |
s: pad(dt.getSeconds()) | |
}; | |
} | |
_toDate(time) { | |
const {yyyy, mm, dd, h, m} = this._padTime(time); | |
return `${yyyy}/${mm}/${dd} ${h}:${m}`; | |
} | |
_toTDate(time) { | |
const {yyyy, mm, dd, h, m, s} = this._padTime(time); | |
return `${yyyy}-${mm}-${dd}T${h}:${m}:${s}`; | |
} | |
_onSubmit() { | |
const val = this._elm.input.value; | |
if (!val || !/^\d\d\d\d-\d\d-\d\dT\d\d:\d\d(|:\d\d)$/.test(val)) { | |
return; | |
} | |
const dt = new Date(val); | |
const when = | |
Math.floor(Math.max(dt.getTime(), this._videoPostTime) / 1000); | |
this.emit('command', 'reloadComment', {when}); | |
this.closeSelect(); | |
} | |
_onCancel() { | |
this.closeSelect(); | |
} | |
_onBack() { | |
this.setState({isWaybackMode: false}); | |
this.closeSelect(); | |
this.emit('command', 'reloadComment', {when: 0}); | |
} | |
} | |
TimeMachineView._shadow_ = (` | |
<style> | |
.dateTime { | |
display: inline-block; | |
margin: auto 4px 4px; | |
padding: 0 4px; | |
border: 1px solid; | |
background: #888; | |
color: #000; | |
font-size: 20px; | |
line-height: 24px; | |
font-family: monospace; | |
cursor: pointer; | |
} | |
.is-WaybackMode .dateTime { | |
background: #000; | |
color: #888; | |
box-shadow: 0 0 4px #ccc, 0 0 4px #ccc inset; | |
} | |
.reloadButton { | |
display: inline-block; | |
line-height: 24px; | |
font-size: 16px; | |
margin: auto 4px; | |
cursor: pointer; | |
user-select: none; | |
transition: transform 0.1s; | |
} | |
.is-WaybackMode .reloadButton { | |
display: none; | |
} | |
.reloadButton .icon { | |
display: inline-block; | |
transform: rotate(90deg) scale(1.3); | |
transition: transform 1s, color 0.2s, text-shadow 0.2s; | |
text-shadow: none; | |
font-family: 'STIXGeneral'; | |
margin-right: 8px; | |
} | |
.reloadButton:hover { | |
text-decoration: underline; | |
} | |
.reloadButton:active { | |
color: #888; | |
cursor: wait; | |
} | |
.reloadButton:active .icon { | |
text-decoration: none; | |
transform: rotate(-270deg) scale(2); | |
transition: none; | |
color: #ff0; | |
text-shadow: 0 0 4px #ff8; | |
} | |
.backToTheFuture { | |
display: none; | |
line-height: 24px; | |
font-size: 16px; | |
margin: auto 4px; | |
cursor: pointer; | |
transition: transform 0.1s; | |
user-select: none; | |
} | |
.backToTheFuture:hover { | |
text-shadow: 0 0 8px #ffc; | |
transform: translate(0, -2px); | |
} | |
.backToTheFuture:active { | |
text-shadow: none; | |
transform: translate(0px, -1000px); | |
} | |
.is-WaybackMode .backToTheFuture { | |
display: inline-block; | |
} | |
.inputContainer { | |
display: none; | |
position: absolute; | |
top: 32px; | |
left: 4px; | |
background: #333; | |
box-shadow: 0 0 4px #fff; | |
} | |
.is-Selecting .inputContainer { | |
display: block; | |
} | |
.dateTimeInput { | |
display: block; | |
font-size: 16px; | |
min-width: 256px; | |
} | |
.submitContainer { | |
text-align: right; | |
} | |
.dateTimeSubmit, .dateTimeCancel { | |
display: inline-block; | |
min-width: 50px; | |
cursor: pointer; | |
padding: 4px 8px; | |
margin: 4px; | |
border: 1px solid #888; | |
text-align: center; | |
transition: background 0.2s, transform 0.2s, box-shadow 0.2s; | |
user-select: none; | |
} | |
.dateTimeSubmit:hover, .dateTimeCancel:hover { | |
background: #666; | |
transform: translate(0, -2px); | |
box-shadow: 0 4px 2px #000; | |
} | |
.dateTimeSubmit:active, .dateTimeCancel:active { | |
background: #333; | |
transform: translate(0, 0); | |
box-shadow: 0 0 2px #000 inset; | |
} | |
.dateTimeSubmit { | |
} | |
.dateTimeCancel { | |
} | |
</style> | |
<div class="root TimeMachine"> | |
<div class="dateTime" title="TimeMachine">0000/00/00 00:00</div> | |
<div class="reloadButton command" data-command="reloadComment" data-param="0" title="コメントのリロード"><span class="icon">↻</span>リロード</div> | |
<div class="backToTheFuture" title="Back To The Future">⮐ Back</div> | |
<div class="inputContainer"> | |
<input type="datetime-local" class="dateTimeInput"> | |
<div class="submitContainer"> | |
<div class="dateTimeSubmit">G O</div> | |
<div class="dateTimeCancel">Cancel</div> | |
</div> | |
</div> | |
</div> | |
`).trim(); | |
TimeMachineView.__tpl__ = ('<div class="TimeMachineView"></div>').trim(); | |
class VideoListItem { | |
static createByThumbInfo(info) { | |
return new this({ | |
_format: 'thumbInfo', | |
id: info.id, | |
title: info.title, | |
length_seconds: info.duration, | |
num_res: info.commentCount, | |
mylist_counter: info.mylistCount, | |
view_counter: info.viewCount, | |
thumbnail_url: info.thumbnail, | |
first_retrieve: info.postedAt, | |
tags: info.tagList, | |
movieType: info.movieType, | |
owner: info.owner, | |
lastResBody: info.lastResBody | |
}); | |
} | |
static createBlankInfo(id) { | |
let postedAt = '0000/00/00 00:00:00'; | |
if (!isNaN(id)) { | |
postedAt = textUtil.dateToString(new Date(id * 1000)); | |
} | |
return new this({ | |
_format: 'blank', | |
id: id, | |
title: id + '(動画情報不明)', | |
length_seconds: 0, | |
num_res: 0, | |
mylist_counter: 0, | |
view_counter: 0, | |
thumbnail_url: 'https://nicovideo.cdn.nimg.jp/web/img/user/thumb/blank_s.jpg', | |
first_retrieve: postedAt, | |
}); | |
} | |
static createByMylistItem(item) { | |
if (item.item_data) { | |
const item_data = item.item_data || {}; | |
return new VideoListItem({ | |
_format: 'mylistItemOldApi', | |
id: item_data.watch_id, | |
uniq_id: item_data.watch_id, | |
title: item_data.title, | |
length_seconds: item_data.length_seconds, | |
num_res: item_data.num_res, | |
mylist_counter: item_data.mylist_counter, | |
view_counter: item_data.view_counter, | |
thumbnail_url: item_data.thumbnail_url, | |
first_retrieve: textUtil.dateToString(new Date(item_data.first_retrieve * 1000)), | |
videoId: item_data.video_id, | |
lastResBody: item_data.last_res_body, | |
mylistItemId: item.item_id, | |
item_type: item.item_type | |
}); | |
} | |
if (!item.length_seconds && typeof item.length === 'string') { | |
const [min, sec] = item.length.split(':'); | |
item.length_seconds = min * 60 + sec * 1; | |
} | |
return new VideoListItem({ | |
_format: 'mylistItemRiapi', | |
id: item.id, | |
uniq_id: item.id, | |
title: item.title, | |
length_seconds: item.length_seconds, | |
num_res: item.num_res, | |
mylist_counter: item.mylist_counter, | |
view_counter: item.view_counter, | |
thumbnail_url: item.thumbnail_url, | |
first_retrieve: item.first_retrieve, | |
lastResBody: item.last_res_body | |
}); | |
} | |
static createByVideoInfoModel(info) { | |
const count = info.count; | |
return new VideoListItem({ | |
_format: 'videoInfo', | |
id: info.watchId, | |
uniq_id: info.contextWatchId, | |
title: info.title, | |
length_seconds: info.duration, | |
num_res: count.comment, | |
mylist_counter: count.mylist, | |
view_counter: count.view, | |
thumbnail_url: info.thumbnail, | |
first_retrieve: info.postedAt, | |
owner: info.owner | |
}); | |
} | |
constructor(rawData) { | |
this._rawData = rawData; | |
this._itemId = VideoListItem._itemId++; | |
this._watchId = (this._getData('id', '') || '').toString(); | |
this._groupList = null; | |
this.state = { | |
isActive: false, | |
lastActivated: rawData.last_activated || 0, | |
isUpdating: false, | |
isPlayed: !!rawData.played, | |
isLazy: true, | |
isDragging: false, | |
isFavorite: false, | |
isDragover: false, | |
isDropped: false, | |
isPocketResolved: false, | |
timestamp: performance.now(), | |
}; | |
this._uniq_id = rawData.uniqId || this.watchId; | |
rawData.first_retrieve = textUtil.dateToString(rawData.first_retrieve); | |
this.notifyUpdate = throttle.raf(this.notifyUpdate.bind(this)); | |
this._sortTitle = textUtil.convertKansuEi(this.title) | |
.replace(/([0-9]{1,9})/g, m => m.padStart(10, '0')).replace(/([0-9]{1,9})/g, m => m.padStart(10, '0')); | |
} | |
equals(item) { | |
return this.uniqId === item.uniqId; | |
} | |
_getData(key, defValue) { | |
return this._rawData.hasOwnProperty(key) ? | |
this._rawData[key] : defValue; | |
} | |
get groupList() { return this._groupList;} | |
set groupList(v) { this._groupList = v;} | |
notifyUpdate() { | |
this.updateTimestamp(); | |
this._groupList && this._groupList.onItemUpdate(this); | |
} | |
get uniqId() { return this._uniq_id;} | |
get itemId() { return this._itemId; } | |
get watchId() { return this._watchId; } | |
set watchId(v) { | |
if (v === this._watchId) { return; } | |
this._watchId = v; | |
this.notifyUpdate(); | |
} | |
get title() { return this._getData('title', ''); } | |
get sortTitle() { return this._sortTitle; } | |
get duration() { return parseInt(this._getData('length_seconds', '0'), 10); } | |
get count() { | |
return { | |
comment: parseInt(this._rawData.num_res, 10), | |
mylist: parseInt(this._rawData.mylist_counter, 10), | |
view: parseInt(this._rawData.view_counter, 10) | |
}; | |
} | |
get thumbnail() { return this._rawData.thumbnail_url; } | |
get postedAt() { return this._rawData.first_retrieve; } | |
get commentCount() { return this.count.comment; } | |
get mylistCount() { return this.count.mylist; } | |
get viewCount() { return this.count.view; } | |
get isActive() { return this.state.isActive; } | |
set isActive(v) { | |
if (this.isActive === v) { return; } | |
this.state.isActive = v; | |
v && (this.state.lastActivated = Date.now()); | |
this.notifyUpdate(); | |
} | |
get isLazy() { return this.state.isLazy; } | |
set isLazy(v) { | |
if (this.isLazy === v) { return; } | |
this.state.isLazy = v; | |
this.notifyUpdate(); | |
} | |
get isDragging() { return this.state.isDragging; } | |
set isDragging(v) { | |
if (this.isDragging === v) { return; } | |
this.state.isDragging = v; | |
this.notifyUpdate(); | |
} | |
get isDragover() { return this.state.isDragover; } | |
set isDragover(v) { | |
if (this.isDragover === v) { return; } | |
this.state.isDragover = v; | |
this.notifyUpdate(); | |
} | |
get isDropped() { return this.state.isDropped; } | |
set isDropped(v) { | |
if (this.isDropped === v) { return; } | |
this.state.isDropped = v; | |
this.notifyUpdate(); | |
} | |
get isUpdating() { return this.state.isUpdating; } | |
set isUpdating(v) { | |
if (this.isUpdating === v) { return; } | |
this.state.isUpdating = v; | |
this.notifyUpdate(); | |
} | |
get isPlayed() { return this.state.isPlayed; } | |
set isPlayed(v) { | |
if (this.isPlayed === v) { return; } | |
this.state.isPlayed = v; | |
this.notifyUpdate(); | |
} | |
get isFavorite() { return this.state.isFavorite; } | |
set isFavorite(v) { | |
if (this.isFavorite === v) { return; } | |
this.state.isFavorite = v; | |
this.notifyUpdate(); | |
} | |
get isPocketResolved() { return this.state.isPocketResolved; } | |
set isPocketResolved(v) { | |
if (this.isPocketResolved === v) { return; } | |
this.state.isPocketResolved = v; | |
this.notifyUpdate(); | |
} | |
get timestamp() { return this.state.timestamp;} | |
updateTimestamp() { this.state.timestamp = performance.now();} | |
get isBlankData() { return this._rawData._format === 'blank'; } | |
remove() { | |
if (!this.groupList) { return; } | |
this.groupList.removeItem(this); | |
this.groupList = null; | |
} | |
serialize() { | |
return { | |
active: this.isActive, | |
last_activated: this.state.lastActivated || 0, | |
played: this.isPlayed, | |
uniq_id: this._uniq_id, | |
id: this._rawData.id, | |
title: this._rawData.title, | |
length_seconds: this._rawData.length_seconds, | |
num_res: this._rawData.num_res, | |
mylist_counter: this._rawData.mylist_counter, | |
view_counter: this._rawData.view_counter, | |
thumbnail_url: this._rawData.thumbnail_url, | |
first_retrieve: this._rawData.first_retrieve, | |
}; | |
} | |
updateByVideoInfo(videoInfo) { | |
const before = JSON.stringify(this.serialize()); | |
const rawData = this._rawData; | |
const count = videoInfo.count; | |
rawData.first_retrieve = textUtil.dateToString(videoInfo.postedAt); | |
rawData.num_res = count.comment; | |
rawData.mylist_counter = count.mylist; | |
rawData.view_counter = count.view; | |
rawData.thumbnail_url = videoInfo.thumbnail; | |
if (JSON.stringify(this.serialize()) !== before) { | |
this.notifyUpdate(); | |
} | |
} | |
} | |
VideoListItem._itemId = 1; | |
class VideoListModel extends Emitter { | |
constructor(params) { | |
super(); | |
this.watchIds = new Map(); | |
this.itemIds = new Map(); | |
this.uset = new Set(); | |
this.initialize(params); | |
this.onUpdate = throttle.raf(this.onUpdate.bind(this)); | |
} | |
initialize(params) { | |
this.isUniq = params.uniq; | |
this.items = []; | |
this.maxItems = params.maxItems || 100; | |
} | |
setItemData(itemData) { | |
itemData = Array.isArray(itemData) ? itemData : [itemData]; | |
const items = itemData.filter(itemData => itemData.has_data) | |
.map(itemData => new VideoListItem(itemData)); | |
this.setItem(items); | |
} | |
setItem(items = []) { | |
items = (Array.isArray(items) ? items : [items]); | |
if (this.isUniq) { | |
const uset = new Set(), iset = new Set(); | |
items = items.filter(item => { | |
const has = uset.has(item.uniqId) || iset.has(item.itemId); | |
uset.add(item.uniqId); | |
iset.add(item.itemId); | |
return !has; | |
}); | |
} | |
this.items = items; | |
this._refreshMaps(); | |
this.onUpdate(); | |
} | |
_refreshMaps() { | |
this.uset.clear(); | |
this.watchIds.clear(); | |
this.itemIds.clear(); | |
this.items.forEach(item => { | |
this.watchIds.set(item.watchId, item); | |
this.itemIds.set(item.itemId, item); | |
this.uset.add(item.uniqId); | |
item.groupList = this; | |
}); | |
} | |
includes(item) { | |
return this.uset.has(item.uniqId) || this.watchIds.has(item.watchId) || this.itemIds.has(item.itemId); | |
} | |
clear() { | |
this.setItem([]); | |
} | |
insertItem(items, index) { | |
items = Array.isArray(items) ? items : [items]; | |
if (this.isUniq) { | |
items = items.filter(item => !this.includes(item)); | |
} | |
if (!items.length) { | |
return; | |
} | |
index = Math.min(this.items.length, (_.isNumber(index) ? index : 0)); | |
Array.prototype.splice.apply(this.items, [index, 0].concat(items)); | |
this.items.splice(this.maxItems); | |
this._refreshMaps(); | |
this.onUpdate(); | |
return this.indexOf(items[0]); | |
} | |
appendItem(items) { | |
items = Array.isArray(items) ? items : [items]; | |
if (this.isUniq) { | |
items = items.filter(item => !this.includes(item)); | |
} | |
if (!items.length) { | |
return; | |
} | |
this.items = this.items.concat(items); | |
while (this.items.length > this.maxItems) { | |
this.items.shift(); | |
} | |
this._refreshMaps(); | |
this.onUpdate(); | |
return this.items.length - 1; | |
} | |
moveItemTo(fromItem, toItem) { | |
fromItem.isUpdating = true; | |
toItem.isUpdating = true; | |
const destIndex = this.indexOf(toItem); | |
this.items = this.items.filter(item => item !== fromItem); | |
this._refreshMaps(); | |
this.insertItem(fromItem, destIndex); | |
this.resetUiFlags([fromItem, toItem]); | |
} | |
resetUiFlags(items) { | |
items = items || this.items; | |
items = Array.isArray(items) ? items : [items]; | |
for (const item of items) { | |
item.isDragging = false; | |
item.isDragover = false; | |
item.isDropped = false; | |
item.isUpdating = false; | |
} | |
} | |
removeByFilter(filterFunc) { | |
const befores = [...this.items]; | |
const afters = this.items.filter(filterFunc); | |
if (befores.length === afters.length) { | |
return false; | |
} | |
for (const item of befores) { | |
!afters.includes(item) && (item.groupList = null); | |
} | |
this.items = afters; | |
this._refreshMaps(); | |
this.onUpdate(); | |
return true; | |
} | |
removePlayedItem() { | |
this.removeByFilter(item => item.isActive || !item.isPlayed); | |
} | |
removeNonActiveItem() { | |
this.removeByFilter(item => item.isActive); | |
} | |
resetPlayedItemFlag() { | |
this.items.forEach(item => item.isPlayed = false); | |
this.onUpdate(); | |
} | |
shuffle() { | |
this.items = _.shuffle(this.items); | |
this.onUpdate(); | |
} | |
indexOf(item) { | |
if (!item || !item.itemId) { return -1; } | |
return this.items.findIndex(i => i.itemId === item.itemId); | |
} | |
getItemByIndex(index) { | |
return this.items[index] || null; | |
} | |
findByItemId(itemId) { | |
itemId = parseInt(itemId, 10); | |
return this.itemIds.get(itemId); | |
} | |
findByWatchId(watchId) { | |
watchId = watchId.toString(); | |
return this.watchIds.get(watchId); | |
} | |
removeItem(...items) { | |
this.removeByFilter(item => !items.includes(item)); | |
} | |
onItemUpdate(item) { | |
this.onUpdate(); | |
} | |
serialize() { | |
return this.items.map(item => item.serialize()); | |
} | |
unserialize(itemDataList) { | |
const items = itemDataList.map(itemData => new VideoListItem(itemData)); | |
this.setItem(items); | |
} | |
sortBy(key, isDesc) { | |
const table = { | |
watchId: 'watchId', | |
duration: 'duration', | |
title: 'sortTitle', | |
comment: 'commentCount', | |
mylist: 'mylistCount', | |
view: 'viewCount', | |
postedAt: 'postedAt', | |
}; | |
const prop = table[key]; | |
if (!prop) { | |
return; | |
} | |
this.items = _.sortBy(this.items, item => item[prop]); | |
if (isDesc) { | |
this.items.reverse(); | |
} | |
this.onUpdate(); | |
} | |
reverse() { | |
this.items.reverse(); | |
this.onUpdate(); | |
} | |
onUpdate() { | |
this.emitAsync('update', this.items); | |
} | |
get length() { | |
return this.items.length; | |
} | |
get activeIndex() { | |
return this.items.findIndex(i => i.isActive); | |
} | |
} | |
class VideoListItemView { | |
static get ITEM_HEIGHT() { return 100; } | |
static get THUMBNAIL_WIDTH() { return 96; } | |
static get THUMBNAIL_HEIGHT() { return 72; } | |
static get CSS() { return ` | |
* { | |
box-sizing: border-box; | |
} | |
.videoItem { | |
position: relative; | |
display: grid; | |
width: 100%; | |
height: 100%; | |
overflow: hidden; | |
grid-template-columns: ${this.THUMBNAIL_WIDTH}px 1fr; | |
grid-template-rows: ${this.THUMBNAIL_HEIGHT}px 1fr; | |
padding: 2px; | |
transition: | |
box-shadow 0.4s ease; | |
contain: layout size paint; | |
/*content-visibility: auto;*/ | |
} | |
.is-updating .videoItem { | |
transition: none !important; | |
} | |
.playlist .videoItem { | |
cursor: move; | |
} | |
.playlist .videoItem.is-inview::before { | |
content: attr(data-index); | |
/*counter-increment: itemIndex;*/ | |
position: absolute; | |
right: 8px; | |
top: 80%; | |
color: #666; | |
font-family: Impact; | |
font-size: 45px; | |
pointer-events: none; | |
z-index: 1; | |
line-height: ${this.ITEM_HEIGHT}px; | |
opacity: 0.6; | |
transform: translate(0, -50%); | |
} | |
.videoItem.is-updating { | |
opacity: 0.3; | |
cursor: wait; | |
} | |
.videoItem.is-updating * { | |
pointer-events: none; | |
} | |
.videoItem.is-dragging { | |
pointer-events: none; | |
box-shadow: 8px 8px 4px #000; | |
background: #666; | |
opacity: 0.8; | |
transform: translate(var(--trans-x-pp), var(--trans-y-pp)); | |
transition: | |
box-shadow 0.4s ease; | |
z-index: 10000; | |
} | |
.videoItem.is-dropped { | |
display: none; | |
} | |
.is-dragging * { | |
cursor: move; | |
} | |
.is-dragging .videoItem.is-dragover { | |
outline: 5px dashed #99f; | |
} | |
.is-dragging .videoItem.is-dragover * { | |
opacity: 0.3; | |
} | |
.videoItem + .videoItem { | |
border-top: 1px dotted var(--item-border-color); | |
margin-top: 4px; | |
outline-offset: -8px; | |
} | |
.videoItem.is-ng-rejected { | |
display: none; | |
} | |
.videoItem.is-fav-favorited .postedAt::after { | |
content: ' ★'; | |
color: #fea; | |
text-shadow: 2px 2px 2px #000; | |
} | |
.thumbnailContainer { | |
position: relative; | |
transform: translate(0, 2px); | |
margin: 0; | |
/*background-color: black;*/ | |
background-size: contain; | |
background-repeat: no-repeat; | |
background-position: center; | |
} | |
.thumbnailContainer a { | |
display: inline-block; | |
width: 100%; | |
height: 100%; | |
transition: box-shaow 0.4s ease, transform 0.4s ease; | |
} | |
.thumbnailContainer a:active { | |
box-shadow: 0 0 8px #f99; | |
transform: translate(0, 4px); | |
transition: none; | |
} | |
.thumbnailContainer .playlistAppend, | |
.playlistRemove, | |
.thumbnailContainer .deflistAdd, | |
.thumbnailContainer .pocket-info { | |
position: absolute; | |
display: none; | |
color: #fff; | |
background: #666; | |
width: 24px; | |
height: 20px; | |
line-height: 18px; | |
font-size: 14px; | |
box-sizing: border-box; | |
text-align: center; | |
font-weight: bolder; | |
color: #fff; | |
cursor: pointer; | |
} | |
.thumbnailContainer .playlistAppend { | |
left: 0; | |
bottom: 0; | |
} | |
.playlistRemove { | |
right: 8px; | |
top: 0; | |
} | |
.thumbnailContainer .deflistAdd { | |
right: 0; | |
bottom: 0; | |
} | |
.thumbnailContainer .pocket-info { | |
display: none !important; | |
right: 24px; | |
bottom: 0; | |
} | |
.is-pocketReady .videoItem:hover .pocket-info { | |
display: inline-block !important; | |
} | |
.playlist .playlistAppend { | |
display: none !important; | |
} | |
.playlistRemove { | |
display: none; | |
} | |
.playlist .videoItem:not(.is-active):hover .playlistRemove { | |
display: inline-block; | |
} | |
.playlist .videoItem:not(.is-active):hover .playlistRemove, | |
.videoItem:hover .thumbnailContainer .playlistAppend, | |
.videoItem:hover .thumbnailContainer .deflistAdd, | |
.videoItem:hover .thumbnailContainer .pocket-info { | |
display: inline-block; | |
border: 1px outset; | |
} | |
.playlist .videoItem:not(.is-active):hover .playlistRemove:hover, | |
.videoItem:hover .thumbnailContainer .playlistAppend:hover, | |
.videoItem:hover .thumbnailContainer .deflistAdd:hover, | |
.videoItem:hover .thumbnailContainer .pocket-info:hover { | |
transform: scale(1.5); | |
box-shadow: 2px 2px 2px #000; | |
} | |
.playlist .videoItem:not(.is-active):hover .playlistRemove:active, | |
.videoItem:hover .thumbnailContainer .playlistAppend:active, | |
.videoItem:hover .thumbnailContainer .deflistAdd:active, | |
.videoItem:hover .thumbnailContainer .pocket-info:active { | |
transform: scale(1.3); | |
border: 1px inset; | |
transition: none; | |
} | |
.videoItem.is-updating .thumbnailContainer .deflistAdd { | |
transform: scale(1.0) !important; | |
border: 1px inset !important; | |
pointer-events: none; | |
} | |
.thumbnailContainer .duration { | |
position: absolute; | |
right: 0; | |
bottom: 0; | |
background: #000; | |
font-size: 12px; | |
color: #fff; | |
} | |
.videoItem:hover .thumbnailContainer .duration { | |
display: none; | |
} | |
.videoInfo { | |
height: 100%; | |
padding-left: 4px; | |
} | |
.postedAt { | |
font-size: 12px; | |
color: #ccc; | |
} | |
.is-played .postedAt::after { | |
content: ' ●'; | |
font-size: 10px; | |
} | |
.counter { | |
position: absolute; | |
top: 80px; | |
width: 100%; | |
text-align: center; | |
} | |
.title { | |
height: 52px; | |
overflow: hidden; | |
} | |
.videoLink { | |
font-size: 14px; | |
color: #ff9; | |
transition: background 0.4s ease, color 0.4s ease; | |
} | |
.videoLink:visited { | |
color: #ffd; | |
} | |
.videoLink:active { | |
color: #fff; | |
background: #663; | |
transition: none; | |
} | |
.noVideoCounter .counter { | |
display: none; | |
} | |
.counter { | |
font-size: 12px; | |
color: #ccc; | |
} | |
.counter .value { | |
font-weight: bolder; | |
} | |
.counter .count { | |
white-space: nowrap; | |
} | |
.counter .count + .count { | |
margin-left: 8px; | |
} | |
.videoItem.is-active { | |
border: none !important; | |
background: #776; | |
} | |
@media screen and (min-width: 600px) | |
{ | |
#listContainerInner { | |
display: grid; | |
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | |
} | |
.videoItem { | |
margin: 4px 8px 0; | |
border-top: none !important; | |
border-bottom: 1px dotted var(--item-border-color); | |
} | |
} | |
`; | |
} | |
static build(item, index = 0) { | |
const {html} = dll.lit; | |
const {classMap} = dll.directives; | |
const addComma = m => isNaN(m) ? '---' : (m.toLocaleString ? m.toLocaleString() : m); | |
const {cache, timestamp, index: _index} = this.map.get(item) || {}; | |
if (cache && timestamp === item.timestamp && index === _index) { | |
return cache; | |
} | |
const title = item.title; | |
const count = item.count; | |
const itemId = item.itemId; | |
const watchId = item.watchId; | |
const watchUrl = `https://www.nicovideo.jp/watch/${watchId}`; | |
const cmap = ({ | |
'videoItem': true, | |
[`watch${watchId}`]: true, | |
[`item${itemId}`]: true, | |
[item.isLazy ? 'is-lazy-load' : 'is-inview'] : true, | |
'is-active': item.isActive, | |
'is-updating': item.isUpdating, | |
'is-played': item.isPlayed, | |
'is-dragging': item.isDragging, | |
'is-dragover': item.isDragover, | |
'is-drropped': item.isDropped, | |
'is-favorite': item.isFavorite, | |
'is-not-resolved': !item.isPocketResolved | |
}); | |
const thumbnailStyle = `background-image: url(${item.thumbnail})`; | |
const result = html` | |
<div class=${classMap(cmap)} data-index=${index + 1} data-item-id=${itemId} data-watch-id=${watchId}> | |
${item.isLazy ? '' : html` | |
<span class="command playlistRemove" data-command="playlistRemove" data-param=${watchId} title="プレイリストから削除">×</span> | |
<div class="thumbnailContainer" style=${thumbnailStyle} data-watch-id=${watchId} data-src=${item.thumbnail}> | |
<a class="command" href=${watchUrl} data-command="select" data-param=${itemId}> | |
<span class="duration">${textUtil.secToTime(item.duration)}</span> | |
</a> | |
<span class="command playlistAppend" data-command="playlistAppend" data-param=${watchId} title="プレイリストに追加">▶</span> | |
<span class="command deflistAdd" data-command="deflistAdd" data-param=${watchId} title="とりあえずマイリスト">✚</span> | |
<span class="command pocket-info" data-command="pocket-info" data-param=${watchId} title="動画情報">?</span> | |
</div> | |
<div class="videoInfo"> | |
<div class="postedAt">${item.postedAt}</div> | |
<div class="title"> | |
<a class="command videoLink" | |
href=${watchUrl} data-command="select" data-param=${itemId} title=${title}>${title}</a> | |
</div> | |
</div> | |
<div class="counter"> | |
<span class="count">再生: <span class="value viewCount">${addComma(count.view)}</span></span> | |
<span class="count">コメ: <span class="value commentCount">${addComma(count.comment)}</span></span> | |
<span class="count">マイ: <span class="value mylistCount">${addComma(count.mylist)}</span></span> | |
</div> | |
`} | |
</div>`; | |
this.map.set(item, {cache: result, timestamp: item.timestamp, index}); | |
return result; | |
} | |
} | |
VideoListItemView.map = new WeakMap; | |
class PlayListModel extends VideoListModel { | |
initialize(params) { | |
super.initialize(params); | |
this.maxItems = 10000; | |
this.items = []; | |
this.isUniq = true; | |
} | |
} | |
class VideoList extends Emitter { | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
initialize(params) { | |
this._thumbInfoLoader = params.loader || ThumbInfoLoader; | |
this._container = params.container; | |
this.model = new VideoListModel({ | |
uniq: true, | |
maxItem: 100 | |
}); | |
this._initializeView(); | |
} | |
_initializeView() { | |
if (this.view) { | |
return; | |
} | |
this.view = new VideoListView({ | |
container: this._container, | |
model: this.model, | |
enablePocketWatch: true | |
}); | |
this.view.on('command', this._onCommand.bind(this)); | |
this.view.on('deflistAdd', bounce.time(this._onDeflistAdd.bind(this), 300)); | |
this.view.on('playlistAppend', bounce.time(this._onPlaylistAppend.bind(this), 300)); | |
} | |
update(listData, watchId) { | |
if (!this.view) { | |
this._initializeView(); | |
} | |
this._watchId = watchId; | |
this.model.setItemData(listData); | |
} | |
_onCommand(command, param) { | |
if (command !== 'select') { | |
return this.emit('command', command, param); | |
} | |
const item = this.model.findByItemId(param); | |
const watchId = item.watchId; | |
this.emit('command', 'open', watchId); | |
} | |
_onPlaylistAppend(watchId, itemId) { | |
this.emit('command', 'playlistAppend', watchId); | |
const item = this.model.findByItemId(itemId) || this.model.findByWatchId(watchId); | |
item.isUpdating = true; | |
window.setTimeout(() => item.isUpdating = false, 1000); | |
} | |
_onDeflistAdd(watchId, itemId) { | |
this.emit('command', 'deflistAdd', watchId); | |
const item = this.model.findByItemId(itemId); | |
item.isUpdating = true; | |
window.setTimeout(() => item.isUpdating = false, 1000); | |
} | |
} | |
class RelatedVideoList extends VideoList { | |
update(listData, watchId) { | |
if (!this.view) { | |
this._initializeView(); | |
} | |
this._watchId = watchId; | |
const items = listData | |
.filter(itemData => itemData.id).map(itemData => new VideoListItem(itemData)); | |
if (!items.length) { | |
return; | |
} | |
this.model.insertItem(items); | |
this.view.scrollTop(0); | |
} | |
async fetchRecommend(videoId, watchId = null, videoInfo = null) { | |
const relatedVideo = []; | |
watchId = watchId || videoId; | |
videoInfo && relatedVideo.push(VideoListItem.createByVideoInfoModel(videoInfo).serialize()); | |
const data = await RecommendAPILoader.load({videoId}).catch(() => ({})); | |
const items = data.items || []; | |
for (const item of items) { | |
if (item.contentType && item.contentType !== 'video') { | |
continue; | |
} | |
const content = item.content; | |
relatedVideo.push({ | |
_format: 'recommendApi', | |
_data: item, | |
id: item.id, | |
title: content.title, | |
length_seconds: content.duration, | |
num_res: content.count.comment, | |
mylist_counter: content.count.mylist, | |
view_counter: content.count.view, | |
thumbnail_url: content.thumbnail.url, | |
first_retrieve: content.registeredAt, | |
has_data: true, | |
is_translated: false | |
}); | |
} | |
this.update(relatedVideo, videoId); | |
} | |
} | |
const PlayListSession = (storage => { | |
const KEY = 'ZenzaWatchPlaylist'; | |
let lastJson = ''; | |
return { | |
isExist() { | |
const data = storage.getItem(KEY); | |
if (!data) { | |
return false; | |
} | |
try { | |
JSON.parse(data); | |
return true; | |
} catch (e) { | |
return false; | |
} | |
}, | |
save(data) { | |
const json = JSON.stringify(data); | |
if (lastJson === json) { return; } | |
lastJson = json; | |
try { | |
storage.setItem(KEY, json); | |
} catch(e) { | |
window.console.error(e); | |
if (e.name === 'QuotaExceededError' || | |
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') { | |
storage.clear(); | |
storage.setItem(KEY, json); | |
} | |
} | |
}, | |
restore() { | |
const data = storage.getItem(KEY); | |
if (!data) { | |
return null; | |
} | |
try { | |
lastJson = data; | |
return JSON.parse(data); | |
} catch (e) { | |
return null; | |
} | |
} | |
}; | |
})(sessionStorage); | |
const PlaylistSession = PlayListSession; | |
class VideoListView extends Emitter { | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
get hasFocus() { | |
return this._hasFocus; | |
} | |
initialize(params) { | |
this._itemCss = params.itemCss || VideoListItemView.CSS; | |
this._className = params.className || 'videoList'; | |
this._retryGetIframeCount = 0; | |
this._maxItems = params.max || 100; | |
this._dragdrop = typeof params.dragdrop === 'boolean' ? params.dragdrop : false; | |
this._dropfile = typeof params.dropfile === 'boolean' ? params.dropfile : false; | |
this._enablePocketWatch = params.enablePocketWatch; | |
this._hasFocus = false; | |
this.items = []; | |
this.model = params.model; | |
if (this.model) { | |
const onUpdate = this._onModelUpdate.bind(this); | |
this.model.on('update', bounce.time(onUpdate)); | |
} | |
this._initializeView(params); | |
} | |
_initializeView(params) { | |
const html = VideoListView.__tpl__.replace('%CSS%', this._itemCss); | |
const frame = this.frameLayer = new FrameLayer({ | |
container: params.container, | |
html, | |
className: 'videoListFrame' | |
}); | |
frame.wait().then(w => this._initializeFrame(w)); | |
} | |
_initializeFrame(w) { | |
this.contentWindow = w; | |
const doc = this.document = w.document; | |
const $body = this.$body = uq(doc.body); | |
this.classList = ClassList(doc.body); | |
if (this._className) { | |
this.addClass(this._className); | |
} | |
cssUtil.registerProps( | |
{name: '--list-length', syntax: '<integer>', initialValue: 1, inherits: true, window: w}, | |
{name: '--active-index', syntax: '<integer>', initialValue: 1, inherits: true, window: w}, | |
{name: '--progress', syntax: '<length-percentage>', initialValue: cssUtil.percent(0), inherits: true, window: w}, | |
); | |
const container = this.listContainer = doc.querySelector('#listContainer'); | |
const list = this.list = doc.getElementById('listContainerInner'); | |
if (this.items && this.items.length) { | |
this.renderList(this.items); | |
} | |
$body.on('click', this._onClick.bind(this)) | |
.on('keydown', e => global.emitter.emit('keydown', e)) | |
.on('keyup', e => global.emitter.emit('keyup', e)); | |
w.addEventListener('focus', () => this._hasFocus = true); | |
w.addEventListener('blur', () => this._hasFocus = false); | |
w.addEventListener('resize', | |
_.debounce(() => this.innerWidth = Math.max(w.innerWidth, 300), 100)); | |
this.innerWidth = Math.max(w.innerWidth, 300); | |
this._updateCSSVars(); | |
if (this._dragdrop) { | |
$body.on('mousedown', this._onBodyMouseDown.bind(this), {passive: true}); | |
} | |
const ccl = ClassList(container); | |
const onScroll = _.throttle(() => { | |
ccl.add('is-scrolling'); | |
onScrollEnd(); | |
}, 100); | |
const onScrollEnd = _.debounce(() => ccl.remove('is-scrolling'), 500); | |
container.addEventListener('scroll', onScroll, {passive: true}); | |
if (this._dropfile) { | |
$body | |
.on('dragover', this._onBodyDragOverFile.bind(this)) | |
.on('dragenter', this._onBodyDragEnterFile.bind(this)) | |
.on('dragleave', this._onBodyDragLeaveFile.bind(this)) | |
.on('drop', this._onBodyDropFile.bind(this)); | |
} | |
MylistPocketDetector.detect().then(async pocket => { | |
this._pocket = pocket; | |
await sleep.idle(); | |
this.addClass('is-pocketReady'); | |
if (pocket.external.observe && this._enablePocketWatch) { | |
pocket.external.observe({ | |
query: '.is-not-resolved a.videoLink', | |
container: list, | |
closest: '.videoItem', | |
callback: this._onMylistPocketInfo.bind(this) | |
}); | |
} | |
}); | |
} | |
_onMylistPocketInfo(itemView, {info, isNg, isFav}) { | |
const item = this.findItemByItemView(itemView); | |
if (!item) { | |
return; | |
} | |
if (isNg) { | |
this.model.removeItem(item); | |
return; | |
} | |
item.isFavorite = isFav; | |
item.isPocketResolved = true; | |
item.watchId = info.watchId; | |
item.info = info; | |
} | |
_onBodyMouseDown(e) { | |
const itemView = e.target.closest('.videoItem'); | |
if (!itemView) { | |
return; | |
} | |
if (e.target.closest('[data-command]')) { | |
return; | |
} | |
const item = this.findItemByItemView(itemView); | |
if (!item) { | |
console.warn('no-item'); | |
return; | |
} | |
const dragOffset = { | |
x: e.pageX, | |
y: e.pageY, | |
st: this.scrollTop() | |
}; | |
this._dragging = {item, itemView, dragOffset, dragOver: {}}; | |
cssUtil.setProps( | |
[itemView, '--trans-x-pp', 0], [itemView, '--trans-y-pp', 0] | |
); | |
this._bindDragStartEvents(); | |
} | |
_bindDragStartEvents() { | |
this.$body | |
.on('mousemove.drag', this._onBodyDragMouseMove.bind(this)) | |
.on('mouseup.drag', this._onBodyDragMouseUp.bind(this)) | |
.on('blur.drag', this._onBodyBlur.bind(this)) | |
.on('mouseleave.drag', this._onBodyMouseLeave.bind(this)); | |
} | |
_unbindDragStartEvents() { | |
this.$body | |
.off('mousemove.drag') | |
.off('mouseup.drag') | |
.off('blur.drag') | |
.off('mouseleave.drag'); | |
} | |
_onBodyDragMouseMove(e) { | |
if (!this._dragging) { | |
return; | |
} | |
const {item, itemView, dragOffset, dragOver} = this._dragging || {}; | |
const x = e.pageX - dragOffset.x; | |
const y = e.pageY - dragOffset.y + (this.scrollTop() - dragOffset.st); | |
if (x * x + y * y < 100) { | |
return; | |
} | |
cssUtil.setProps( | |
[itemView, '--trans-x-pp', cssUtil.px(x)], [itemView, '--trans-y-pp', cssUtil.px(y)] | |
); | |
item.isDragging = true; | |
this.addClass('is-dragging'); | |
const targetView = e.target.closest('.videoItem'); | |
if (!targetView) { | |
dragOver && dragOver.item && (dragOver.item.isDragover = false); | |
this._dragging.dragOver = null; | |
return; | |
} | |
const targetItem = this.findItemByItemView(targetView); | |
if (!targetItem || (dragOver && dragOver.item === targetItem)) { | |
return; | |
} | |
dragOver && dragOver.item && (dragOver.item.isDragover = false); | |
targetItem.isDragover = true; | |
this._dragging.dragOver = { | |
item: targetItem, | |
itemView: targetView | |
}; | |
} | |
_onBodyDragMouseUp(e) { | |
this._unbindDragStartEvents(); | |
if (!this._dragging) { | |
return; | |
} | |
const {item, itemView, dragOver} = this._dragging || {}; | |
this._endBodyMouseDragging(); | |
const {item: targetItem, itemView: targetView} = dragOver || {}; | |
if (!targetView || itemView === targetView) { | |
return; | |
} | |
item.isUpdating = true; | |
this.addClass('is-updating'); | |
this._dragging = null; | |
this.emit('moveItem', item.itemId, targetItem.itemId); | |
} | |
_onBodyBlur() { | |
this._endBodyMouseDragging(); | |
} | |
_onBodyMouseLeave() { | |
this._endBodyMouseDragging(); | |
} | |
_endBodyMouseDragging() { | |
this._unbindDragStartEvents(); | |
this.removeClass('is-dragging'); | |
const {item} = this._dragging || {}; | |
item && (item.isDragging = false); | |
this._dragging = null; | |
} | |
_onBodyDragOverFile(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.addClass('is-dragover'); | |
} | |
_onBodyDragEnterFile(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.addClass('is-dragover'); | |
} | |
_onBodyDragLeaveFile(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.removeClass('is-dragover'); | |
} | |
_onBodyDropFile(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.removeClass('is-dragover'); | |
const file = e.originalEvent.dataTransfer.files[0]; | |
if (!/\.playlist\.json$/.test(file.name)) { | |
return; | |
} | |
const fileReader = new FileReader(); | |
fileReader.onload = ev => { | |
window.console.log('file data: ', ev.target.result); | |
this.emit('filedrop', ev.target.result, file.name); | |
}; | |
fileReader.readAsText(file); | |
} | |
async _onModelUpdate(items) { | |
this.items = items; | |
this.addClass('is-updating'); | |
await this.renderList(items); | |
this.removeClass('is-updating'); | |
this.emit('update'); | |
} | |
findItemByItemView(itemView) { | |
const itemId = itemView.dataset.itemId * 1; | |
return this.model.findByItemId(itemId); | |
} | |
async renderList(items) { | |
if (!this.list) { return; } | |
items = items || this.items || []; | |
const lit = dll.lit || await global.emitter.promise('lit-html'); | |
const {render} = lit; | |
const timeLabel = `update playlistView items = ${items.length}`; | |
console.time(timeLabel); | |
render(await this._buildList(items), this.list); | |
console.timeEnd(timeLabel); | |
this._updateCSSVars(); | |
this._setInviewObserver(); | |
} | |
async _buildList(items) { | |
items = items || this.items || []; | |
const lit = dll.lit || await global.emitter.promise('lit-html'); | |
const {html} = lit; | |
const mapper = (item, index) => VideoListItemView.build(item, index); | |
const result = html`${items.map(mapper)}`; | |
this.lastBuild = {result, time: performance.now()}; | |
return result; | |
} | |
_setInviewObserver() { | |
if (!this.document) { | |
return; | |
} | |
if (this.intersectionObserver) { | |
this.intersectionObserver.disconnect(); | |
} | |
const targets = [...this.document.querySelectorAll('.videoItem')]; | |
if (!targets.length) { return; } | |
const onInview = this._boundOnItemInview = | |
this._boundOnItemInview || this._onItemInview.bind(this); | |
const observer = this.intersectionObserver = | |
new this.contentWindow.IntersectionObserver(onInview, {rootMargin: '800px', root: this.listContainer}); | |
targets.forEach(target => observer.observe(target)); | |
} | |
_onItemInview(entries) { | |
for (const entry of entries) { | |
const itemView = entry.target; | |
const item = this.findItemByItemView(itemView); | |
if (!item) { continue; } | |
item.isLazy = !entry.isIntersecting; | |
} | |
} | |
_updateCSSVars() { | |
if (!this.document) { return; } | |
const body = this.document.body; | |
cssUtil.setProps( | |
[body, '--list-length', cssUtil.number(this.model.length)], | |
[body, '--active-index', cssUtil.number(this.model.activeIndex)] | |
); | |
} | |
_onClick(e) { | |
e.stopPropagation(); | |
global.emitter.emitAsync('hideHover'); | |
const target = e.target.closest('.command'); | |
const itemView = e.target.closest('.videoItem'); | |
const item = itemView ? this.findItemByItemView(itemView) : null; | |
if (!target) { | |
return; | |
} | |
e.preventDefault(); | |
const {command, param} = target.dataset; | |
const itemId = item ? item.itemId : 0; | |
switch (command) { | |
case 'deflistAdd': | |
this.emit('deflistAdd', param, itemId); | |
break; | |
case 'playlistAppend': | |
this.emit('playlistAppend', param, itemId); | |
break; | |
case 'pocket-info': | |
window.setTimeout(() => this._pocket.external.info(param), 100); | |
break; | |
case 'scrollToTop': | |
this.scrollTop(0, 300); | |
break; | |
case 'playlistRemove': | |
item && (item.isUpdating = true); | |
this.emit('command', command, param, itemId); | |
break; | |
default: | |
this.emit('command', command, param, itemId); | |
} | |
} | |
addClass(name) { | |
this.classList && this.classList.add(name); | |
} | |
removeClass(name) { | |
this.classList && this.classList.remove(name); | |
} | |
toggleClass(name, v) { | |
this.classList && this.classList.toggle(name, v); | |
} | |
scrollTop(v) { | |
if (!this.listContainer) { | |
return 0; | |
} | |
if (typeof v === 'number') { | |
this.listContainer.scrollTop = v; | |
} else { | |
return this.listContainer.scrollTop; | |
} | |
} | |
scrollToItem(itemId) { | |
if (!this.$body) { | |
return; | |
} | |
if (typeof itemId === 'object') { | |
itemId = itemId.itemId; | |
} | |
const $target = this.$body.find(`.item${itemId}`); | |
if (!$target.length) { | |
return; | |
} | |
$target[0].scrollIntoView({block: 'start', behavior: 'instant'}); | |
} | |
} | |
VideoListView.__tpl__ = (` | |
<!DOCTYPE html> | |
<html lang="ja"> | |
<head> | |
<meta charset="utf-8"> | |
<title>VideoList</title> | |
<style type="text/css"> | |
${CONSTANT.BASE_CSS_VARS} | |
${CONSTANT.SCROLLBAR_CSS} | |
body { | |
user-select: none; | |
background: #333; | |
overflow: hidden; | |
} | |
.drag-over>* { | |
opacity: 0.5; | |
pointer-events: none; | |
} | |
.is-updating #listContainer { | |
pointer-events: none; | |
opacity: 0.5; | |
transition: none; | |
} | |
#listContainer { | |
position: absolute; | |
top: 0; | |
left:0; | |
margin: 0; | |
padding: 0; | |
width: 100vw; | |
height: 100vh; | |
overflow-x: hidden; | |
overflow-y: scroll; | |
overscroll-behavior: none; | |
transition: 0.2s opacity; | |
counter-reset: itemIndex; | |
will-change: transform; | |
} | |
#listContainerInner { | |
display: grid; | |
grid-auto-rows: 100px; | |
} | |
.is-scrolling #listContainerInner { | |
pointer-events: none; | |
animation-play-state: paused !important; | |
} | |
.scrollToTop, .scrollToActive { | |
position: fixed; | |
width: 32px; | |
height: 32px; | |
right: 48px; | |
bottom: 8px; | |
font-size: 24px; | |
line-height: 32px; | |
text-align: center; | |
z-index: 100; | |
background: #ccc; | |
color: #000; | |
border-radius: 100%; | |
cursor: pointer; | |
opacity: 0.3; | |
transition: opacity 0.4s ease; | |
} | |
.scrollToActive { | |
--progress: calc(var(--active-index) / var(--list-length) * 100%); | |
display: none; | |
top: var(--progress); | |
border-radius: 0; | |
bottom: auto; | |
right: 0; | |
transform: translateY(calc(var(--progress) * -1)); | |
background: none; | |
opacity: 0.5; | |
color: #f99; | |
} | |
.playlist .scrollToActive { | |
display: block; | |
} | |
.playlist .scrollToActive:hover { | |
background: #ccc; | |
} | |
.scrollToTop:hover { | |
opacity: 0.9; | |
box-shadow: 0 0 8px #fff; | |
} | |
</style> | |
<style id="listItemStyle">%CSS%</style> | |
<body class="zenzaRoot"> | |
<div id="listContainer"> | |
<div id="listContainerInner"></div> | |
</div> | |
<div class="scrollToActive command" title="いまここ" data-command="scrollToActiveItem">►</div> | |
<div class="scrollToTop command" title="一番上にスクロール" data-command="scrollToTop">⌃</div> | |
</body> | |
</html> | |
`).trim(); | |
class PlayListView extends Emitter { | |
constructor(...args) { | |
super(...args); | |
this.initialize(...args); | |
} | |
initialize(params) { | |
this._container = params.container; | |
this._model = params.model; | |
this._playlist = params.playlist; | |
cssUtil.addStyle(PlayListView.__css__); | |
const $view = this._$view = uq.html(PlayListView.__tpl__); | |
this.classList = ClassList($view[0]); | |
this._container.append($view[0]); | |
const mq = $view.mapQuery({ | |
_index: '.playlist-index', _length: '.playlist-length', | |
_menu: '.playlist-menu', _fileDrop: '.playlist-file-drop', | |
_fileSelect: '.import-playlist-file-select', | |
_playlistFrame: '.playlist-frame' | |
}); | |
Object.assign(this, mq.e); | |
Object.assign(this, mq.$); | |
global.debug.playlistView = this._$view; | |
const listView = this._listView = new VideoListView({ | |
container: this._playlistFrame, | |
model: this._model, | |
className: 'playlist', | |
dragdrop: true, | |
dropfile: true, | |
enablePocketWatch: false | |
}); | |
listView.on('command', this._onCommand.bind(this)); | |
listView.on('deflistAdd', this._onDeflistAdd.bind(this)); | |
listView.on('moveItem', (src, dest) => this.emit('moveItem', src, dest)); | |
listView.on('filedrop', data => this.emit('command', 'importFile', data)); | |
this._playlist.on('update', | |
_.debounce(this._onPlaylistStatusUpdate.bind(this), 100)); | |
this._$view.on('click', this._onPlaylistCommandClick.bind(this)); | |
global.emitter.on('hideHover', () => { | |
this._$menu.raf.removeClass('show'); | |
this._$fileDrop.raf.removeClass('show'); | |
}); | |
uq('.zenzaVideoPlayerDialog') | |
.on('dragover', this._onDragOverFile.bind(this)) | |
.on('dragenter', this._onDragEnterFile.bind(this)) | |
.on('dragleave', this._onDragLeaveFile.bind(this)) | |
.on('drop', this._onDropFile.bind(this)); | |
this._$fileSelect.on('change', this._onImportFileSelect.bind(this)); | |
['addClass', | |
'removeClass', | |
'scrollTop', | |
'scrollToItem', | |
].forEach(func => this[func] = listView[func].bind(listView)); | |
} | |
toggleClass(className, v) { | |
this.classList.toggle(className, v); | |
} | |
_onCommand(command, param, itemId) { | |
switch (command) { | |
default: | |
this.emit('command', command, param, itemId); | |
break; | |
} | |
} | |
_onDeflistAdd(watchId, itemId) { | |
this.emit('deflistAdd', watchId, itemId); | |
} | |
_onPlaylistCommandClick(e) { | |
const target = e.target.closest('.playlist-command'); | |
if (!target) { | |
return; | |
} | |
const {command, param} = target.dataset; | |
e.stopPropagation(); | |
if (!command) { | |
return; | |
} | |
switch (command) { | |
case 'importFileMenu': | |
this._$menu.raf.removeClass('show'); | |
this._$fileDrop.addClass('show'); | |
return; | |
case 'toggleMenu': | |
e.stopPropagation(); | |
e.preventDefault(); | |
this._$menu.raf.addClass('show'); | |
return; | |
case 'shuffle': | |
case 'sortBy': | |
case 'reverse': | |
this.classList.add('shuffle'); | |
window.setTimeout(() => this.classList.remove('shuffle'), 1000); | |
this.emit('command', command, param); | |
break; | |
default: | |
this.emit('command', command, param); | |
} | |
global.emitter.emitAsync('hideHover'); | |
} | |
_onPlaylistStatusUpdate() { | |
const playlist = this._playlist; | |
this.classList.toggle('enable', playlist.isEnable); | |
this.classList.toggle('loop', playlist.isLoop); | |
this._index.textContent = playlist.getIndex() + 1; | |
this._length.textContent = playlist.length; | |
} | |
_onDragOverFile(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this._$fileDrop.addClass('is-dragover'); | |
} | |
_onDragEnterFile(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this._$fileDrop.addClass('is-dragover'); | |
} | |
_onDragLeaveFile(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this._$fileDrop.removeClass('is-dragover'); | |
} | |
_onDropFile(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
this._$fileDrop.removeClass('show is-dragover'); | |
const file = (e.originalEvent || e).dataTransfer.files[0]; | |
if (!/\.playlist\.json$/.test(file.name)) { | |
return; | |
} | |
const fileReader = new FileReader(); | |
fileReader.onload = ev => { | |
window.console.log('file data: ', ev.target.result); | |
this.emit('command', 'importFile', ev.target.result); | |
}; | |
fileReader.readAsText(file); | |
} | |
_onImportFileSelect(e) { | |
e.preventDefault(); | |
const file = (e.originalEvent || e).target.files[0]; | |
if (!/\.playlist\.json$/.test(file.name)) { | |
return; | |
} | |
const fileReader = new FileReader(); | |
fileReader.onload = ev => { | |
window.console.log('file data: ', ev.target.result); | |
this.emit('command', 'importFile', ev.target.result); | |
}; | |
fileReader.readAsText(file); | |
} | |
get hasFocus() { | |
return this._listView.hasFocus; | |
} | |
} | |
PlayListView.__css__ = (` | |
.is-playlistEnable .tabSelect.playlist::after { | |
content: '▶'; | |
color: #fff; | |
text-shadow: 0 0 8px orange; | |
} | |
.zenzaScreenMode_sideView .is-playlistEnable .is-notFullscreen .tabSelect.playlist::after { | |
text-shadow: 0 0 8px #336; | |
} | |
.playlist-container { | |
height: 100%; | |
overflow: hidden; | |
user-select: none; | |
} | |
.playlist-header { | |
height: 32px; | |
border-bottom: 1px solid #000; | |
background: #333; | |
color: #ccc; | |
user-select: none; | |
} | |
.playlist-menu-button { | |
display: inline-block; | |
cursor: pointer; | |
border: 1px solid #333; | |
padding: 0px 4px; | |
margin: 0 0 0 4px; | |
background: #666; | |
font-size: 16px; | |
line-height: 28px; | |
white-space: nowrap; | |
} | |
.playlist-menu-button:hover { | |
border: 1px outset; | |
} | |
.playlist-menu-button:active { | |
border: 1px inset; | |
} | |
.playlist-menu-button .playlist-menu-icon { | |
font-size: 24px; | |
line-height: 28px; | |
} | |
.playlist-container.enable .toggleEnable, | |
.playlist-container.loop .toggleLoop { | |
text-shadow: 0 0 6px #f99; | |
color: #ff9; | |
} | |
.playlist-container .toggleLoop icon { | |
font-family: STIXGeneral; | |
} | |
.playlist-container .shuffle { | |
font-size: 14px; | |
} | |
.playlist-container .shuffle::after { | |
content: '(´・ω・`)'; | |
} | |
.playlist-container .shuffle:hover::after { | |
content: '(`・ω・´)'; | |
} | |
.playlist-frame { | |
height: calc(100% - 32px); | |
transition: opacity 0.3s; | |
} | |
.shuffle .playlist-frame { | |
opacity: 0; | |
} | |
.playlist-count { | |
position: absolute; | |
top: 0; | |
right: 8px; | |
display: inline-block; | |
font-size: 14px; | |
line-height: 32px; | |
cursor: pointer; | |
} | |
.playlist-count:before { | |
content: '▼'; | |
} | |
.playlist-count:hover { | |
text-decoration: underline; | |
} | |
.playlist-menu { | |
position: absolute; | |
right: 0px; | |
top: 24px; | |
min-width: 150px; | |
background: #333 !important; | |
} | |
.playlist-menu li { | |
line-height: 20px; | |
border: none !important; | |
} | |
.playlist-menu .separator { | |
border: 1px inset; | |
border-radius: 3px; | |
margin: 8px 8px; | |
} | |
.playlist-file-drop { | |
display: none; | |
position: absolute; | |
width: 94%; | |
height: 94%; | |
top: 3%; | |
left: 3%; | |
background: #000; | |
color: #ccc; | |
opacity: 0.8; | |
border: 2px solid #ccc; | |
box-shadow: 0 0 4px #fff; | |
padding: 16px; | |
z-index: 100; | |
} | |
.playlist-file-drop.show { | |
opacity: 0.98 !important; | |
} | |
.playlist-file-drop.drag-over { | |
box-shadow: 0 0 8px #fe9; | |
background: #030; | |
} | |
.playlist-file-drop * { | |
pointer-events: none; | |
} | |
.playlist-file-drop-inner { | |
padding: 8px; | |
height: 100%; | |
border: 1px dotted #888; | |
} | |
.import-playlist-file-select { | |
position: absolute; | |
text-indent: -9999px; | |
width: 100%; | |
height: 20px; | |
opacity: 0; | |
cursor: pointer; | |
} | |
`).trim(); | |
PlayListView.__tpl__ = (` | |
<div class="playlist-container"> | |
<div class="playlist-header"> | |
<label class="playlist-menu-button toggleEnable playlist-command" | |
data-command="toggleEnable"><icon class="playlist-menu-icon">▶</icon> 連続再生</label> | |
<label class="playlist-menu-button toggleLoop playlist-command" | |
data-command="toggleLoop"><icon class="playlist-menu-icon">↻</icon> リピート</label> | |
<div class="playlist-count playlist-command" data-command="toggleMenu"> | |
<span class="playlist-index">---</span> / <span class="playlist-length">---</span> | |
<div class="zenzaPopupMenu playlist-menu"> | |
<div class="listInner"> | |
<ul> | |
<li class="playlist-command" data-command="shuffle"> | |
シャッフル | |
</li> | |
<li class="playlist-command" data-command="reverse"> | |
逆順にする | |
</li> | |
<li class="playlist-command" data-command="sortBy" data-param="postedAt"> | |
古い順に並べる | |
</li> | |
<li class="playlist-command" data-command="sortBy" data-param="view:desc"> | |
再生の多い順に並べる | |
</li> | |
<li class="playlist-command" data-command="sortBy" data-param="comment:desc"> | |
コメントの多い順に並べる | |
</li> | |
<li class="playlist-command" data-command="sortBy" data-param="title"> | |
タイトル順に並べる | |
</li> | |
<li class="playlist-command" data-command="sortBy" data-param="duration:desc"> | |
動画の長い順に並べる | |
</li> | |
<li class="playlist-command" data-command="sortBy" data-param="duration"> | |
動画の短い順に並べる | |
</li> | |
<hr class="separator"> | |
<li class="playlist-command" data-command="exportFile">ファイルに保存 💾</li> | |
<li class="playlist-command" data-command="importFileMenu"> | |
<input type="file" class="import-playlist-file-select" accept=".json"> | |
ファイルから読み込む | |
</li> | |
<hr class="separator"> | |
<li class="playlist-command" data-command="resetPlayedItemFlag">すべて未視聴にする</li> | |
<li class="playlist-command" data-command="removePlayedItem">視聴済み動画を消す ●</li> | |
<li class="playlist-command" data-command="removeNonActiveItem">リストの消去 ×</li> | |
</ul> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="playlist-frame"></div> | |
<div class="playlist-file-drop"> | |
<div class="playlist-file-drop-inner"> | |
ファイルをここにドロップ | |
</div> | |
</div> | |
</div> | |
`).trim(); | |
class PlayList extends VideoList { | |
initialize(params) { | |
this._thumbInfoLoader = params.loader || global.api.ThumbInfoLoader; | |
this._container = params.container; | |
this._index = -1; | |
this._isEnable = false; | |
this._isLoop = params.loop; | |
this.model = new PlayListModel({}); | |
global.debug.playlist = this; | |
this.on('update', _.debounce(() => PlayListSession.save(this.serialize()), 3000)); | |
global.emitter.on('tabChange', tab => { | |
if (tab === 'playlist') { | |
this.scrollToActiveItem(); | |
} | |
}); | |
} | |
serialize() { | |
return { | |
items: this.model.serialize(), | |
index: this._index, | |
enable: this._isEnable, | |
loop: this._isLoop | |
}; | |
} | |
unserialize(data) { | |
if (!data) { | |
return; | |
} | |
this._initializeView(); | |
console.log('unserialize: ', data); | |
this.model.unserialize(data.items); | |
this._isEnable = data.enable; | |
this._isLoop = data.loop; | |
this.emit('update'); | |
this.setIndex(data.index); | |
} | |
restoreFromSession() { | |
this.unserialize(PlayListSession.restore()); | |
} | |
_initializeView() { | |
if (this.view) { | |
return; | |
} | |
this.view = new PlayListView({ | |
container: this._container, | |
model: this.model, | |
playlist: this | |
}); | |
this.view.on('command', this._onCommand.bind(this)); | |
this.view.on('deflistAdd', this._onDeflistAdd.bind(this)); | |
this.view.on('moveItem', this._onMoveItem.bind(this)); | |
} | |
_onCommand(command, param, itemId) { | |
let item; | |
switch (command) { | |
case 'toggleEnable': | |
this.toggleEnable(); | |
break; | |
case 'toggleLoop': | |
this.toggleLoop(); | |
break; | |
case 'shuffle': | |
this.shuffle(); | |
break; | |
case 'reverse': | |
this.model.reverse(); | |
break; | |
case 'sortBy': { | |
let [key, order] = param.split(':'); | |
this.sortBy(key, order === 'desc'); | |
break; | |
} | |
case 'clear': | |
this._setItemData([]); | |
break; | |
case 'select': | |
item = this.model.findByItemId(itemId); | |
this.setIndex(this.model.indexOf(item)); | |
this.emit('command', 'openNow', item.watchId); | |
break; | |
case 'playlistRemove': | |
item = this.model.findByItemId(itemId); | |
this.model.removeItem(item); | |
this._refreshIndex(); | |
this.emit('update'); | |
break; | |
case 'removePlayedItem': | |
this.removePlayedItem(); | |
break; | |
case 'resetPlayedItemFlag': | |
this.model.resetPlayedItemFlag(); | |
break; | |
case 'removeNonActiveItem': | |
this.removeNonActiveItem(); | |
break; | |
case 'exportFile': | |
this._onExportFileCommand(); | |
break; | |
case 'importFile': | |
this._onImportFileCommand(param); | |
break; | |
case 'scrollToActiveItem': | |
this.scrollToActiveItem(true); | |
break; | |
default: | |
this.emit('command', command, param); | |
} | |
} | |
_onExportFileCommand() { | |
const dt = new Date(); | |
const title = prompt('プレイリストを保存\nプレイヤーにドロップすると復元されます', | |
textUtil.dateToString(dt) + 'のプレイリスト'); | |
if (!title) { | |
return; | |
} | |
const data = JSON.stringify(this.serialize(), null, 2); | |
const blob = new Blob([data], {'type': 'text/html'}); | |
const url = window.URL.createObjectURL(blob); | |
const a = document.createElement('a'); | |
Object.assign(a, { | |
download: title + '.playlist.json', | |
rel: 'noopener', | |
href: url | |
}); | |
document.body.append(a); | |
a.click(); | |
setTimeout(() => a.remove(), 1000); | |
} | |
_onImportFileCommand(fileData) { | |
if (!textUtil.isValidJson(fileData)) { | |
return; | |
} | |
this.emit('command', 'pause'); | |
this.emit('command', 'notify', 'プレイリストを復元'); | |
this.unserialize(JSON.parse(fileData)); | |
window.setTimeout(() => { | |
const index = Math.max(0, fileData.index || 0); | |
const item = this.model.getItemByIndex(index); | |
if (item) { | |
this.setIndex(index, true); | |
this.emit('command', 'openNow', item.watchId); | |
} | |
}, 2000); | |
} | |
_onMoveItem(fromItemId, toItemId) { | |
const fromItem = this.model.findByItemId(fromItemId); | |
const toItem = this.model.findByItemId(toItemId); | |
if (!fromItem || !toItem) { | |
return; | |
} | |
this.model.moveItemTo(fromItem, toItem); | |
this._refreshIndex(); | |
} | |
_setItemData(listData) { | |
const items = listData.map(itemData => new VideoListItem(itemData)); | |
this.model.setItem(items); | |
this.setIndex(items.length > 0 ? 0 : -1); | |
} | |
_replaceAll(videoListItems, options) { | |
options = options || {}; | |
this.model.setItem(videoListItems); | |
const item = this.model.findByWatchId(options.watchId); | |
if (item) { | |
item.isActive = true; | |
item.isPlayed = true; | |
this._activeItem = item; | |
setTimeout(() => this.view.scrollToItem(item), 1000); | |
} | |
this.setIndex(this.model.indexOf(item)); | |
} | |
_appendAll(videoListItems, options) { | |
options = options || {}; | |
this.model.appendItem(videoListItems); | |
const item = this.model.findByWatchId(options.watchId); | |
if (item) { | |
item.isActive = true; | |
item.isPlayed = true; | |
this._refreshIndex(false); | |
} | |
setTimeout(() => this.view.scrollToItem(videoListItems[0]), 1000); | |
} | |
_insertAll(videoListItems, options) { | |
options = options || {}; | |
this.model.insertItem( | |
videoListItems, | |
this.getIndex() + 1); | |
const item = this.model.findByWatchId(options.watchId); | |
if (item) { | |
item.isActive = true; | |
item.isPlayed = true; | |
this._refreshIndex(false); | |
} | |
setTimeout(() => this.view.scrollToItem(videoListItems[0]), 1000); | |
} | |
replaceItems(videoListItemsRawData, options) { | |
const items = videoListItemsRawData.map(raw => new VideoListItem(raw)); | |
return this._replaceAll(items, options); | |
} | |
appendItems(videoListItemsRawData, options) { | |
const items = videoListItemsRawData.map(raw => new VideoListItem(raw)); | |
return this._appendAll(items, options); | |
} | |
insertItems(videoListItemsRawData, options) { | |
const items = videoListItemsRawData.map(raw => new VideoListItem(raw)); | |
return this._insertAll(items, options); | |
} | |
loadFromMylist(mylistId, options) { | |
this._initializeView(); | |
if (!this._mylistApiLoader) { | |
this._mylistApiLoader = MylistApiLoader; | |
} | |
window.console.time('loadMylist: ' + mylistId); | |
return this._mylistApiLoader | |
.getMylistItems(mylistId, options).then(items => { | |
window.console.timeEnd('loadMylist: ' + mylistId); | |
let videoListItems = items.filter(item => { | |
if (item.id === null) { | |
return; | |
} // ごく稀にある?idが抹消されたレコード | |
if (item.item_data) { | |
if (parseInt(item.item_type, 10) !== 0) { | |
return; | |
} // not video | |
if (parseInt(item.item_data.deleted, 10) !== 0) { | |
return; | |
} // 削除動画を除外 | |
} else { | |
if (item.thumbnail_url && item.thumbnail_url.indexOf('video_deleted') >= 0) { | |
return; | |
} | |
} | |
return true; | |
}).map(item => VideoListItem.createByMylistItem(item)); | |
if (videoListItems.length < 1) { | |
return Promise.reject({ | |
status: 'fail', | |
message: 'マイリストの取得に失敗しました' | |
}); | |
} | |
if (options.shuffle) { | |
videoListItems = _.shuffle(videoListItems); | |
} | |
if (options.insert) { | |
this._insertAll(videoListItems, options); | |
} else if (options.append) { | |
this._appendAll(videoListItems, options); | |
} else { | |
this._replaceAll(videoListItems, options); | |
} | |
this.emit('update'); | |
return Promise.resolve({ | |
status: 'ok', | |
message: | |
options.append ? | |
'マイリストの内容をプレイリストに追加しました' : | |
'マイリストの内容をプレイリストに読み込みしました' | |
}); | |
}); | |
} | |
loadUploadedVideo(userId, options) { | |
this._initializeView(); | |
if (!this._uploadedVideoApiLoader) { | |
this._uploadedVideoApiLoader = UploadedVideoApiLoader; | |
} | |
window.console.time('loadUploadedVideos' + userId); | |
return this._uploadedVideoApiLoader | |
.load(userId, options).then(items => { | |
window.console.timeEnd('loadUploadedVideos' + userId); | |
let videoListItems = items.map(item => VideoListItem.createByMylistItem(item)); | |
if (videoListItems.length < 1) { | |
return Promise.reject({}); | |
} | |
videoListItems.reverse(); | |
if (options.shuffle) { | |
videoListItems = _.shuffle(videoListItems); | |
} | |
if (options.insert) { | |
this._insertAll(videoListItems, options); | |
} else if (options.append) { | |
this._appendAll(videoListItems, options); | |
} else { | |
this._replaceAll(videoListItems, options); | |
} | |
this.emit('update'); | |
return Promise.resolve({ | |
status: 'ok', | |
message: | |
options.append ? | |
'投稿動画一覧をプレイリストに追加しました' : | |
'投稿動画一覧をプレイリストに読み込みしました' | |
}); | |
}); | |
} | |
loadSearchVideo(word, options, limit = 300) { | |
this._initializeView(); | |
if (!this._searchApiLoader) { | |
this._nicoSearchApiLoader = NicoSearchApiV2Loader; | |
} | |
window.console.time('loadSearchVideos' + word); | |
options = options || {}; | |
return this._nicoSearchApiLoader | |
.searchMore(word, options, limit).then(result => { | |
window.console.timeEnd('loadSearchVideos' + word); | |
const items = result.list || []; | |
let videoListItems = items | |
.filter(item => { | |
return (item.item_data && | |
parseInt(item.item_data.deleted, 10) === 0) || | |
(item.thumbnail_url || '').indexOf('video_deleted') < 0; | |
}).map(item => VideoListItem.createByMylistItem(item)); | |
if (videoListItems.length < 1) { | |
return Promise.reject({}); | |
} | |
if (options.playlistSort) { | |
videoListItems = _.sortBy( | |
videoListItems, item => item.postedAt + item.sortTitle); | |
} | |
if (options.shuffle) { | |
videoListItems = _.shuffle(videoListItems); | |
} | |
if (options.insert) { | |
this._insertAll(videoListItems, options); | |
} else if (options.append) { | |
this._appendAll(videoListItems, options); | |
} else { | |
this._replaceAll(videoListItems, options); | |
} | |
this.emit('update'); | |
return Promise.resolve({ | |
status: 'ok', | |
message: | |
options.append ? | |
'検索結果をプレイリストに追加しました' : | |
'検索結果をプレイリストに読み込みしました' | |
}); | |
}); | |
} | |
async loadSeriesList(seriesId, options = {}) { | |
this._initializeView(); | |
const data = await RecommendAPILoader.loadSeries(seriesId, options); | |
const videoItems = []; | |
(data.items || []).forEach(item => { | |
if (item.contentType !== 'video') { | |
return; | |
} | |
const content = item.content; | |
videoItems.push(new VideoListItem({ | |
_format: 'recommendApi', | |
_data: item, | |
id: item.id, | |
uniq_id: item.id, | |
title: content.title, | |
length_seconds: content.duration, | |
num_res: content.count.comment, | |
mylist_counter: content.count.mylist, | |
view_counter: content.count.view, | |
thumbnail_url: content.thumbnail.url, | |
first_retrieve: content.registeredAt, | |
has_data: true, | |
is_translated: false | |
})); | |
}); | |
if (options.insert) { | |
this._insertAll(videoItems, options); | |
} else if (options.append) { | |
this._appendAll(videoItems, options); | |
} else { | |
this._replaceAll(videoItems, options); | |
} | |
this.emit('update'); | |
return { | |
status: 'ok', | |
message: | |
options.append ? '動画シリーズをプレイリストに追加しました' : '動画シリーズをプレイリストに読み込みしました' | |
}; | |
} | |
insert(watchId) { | |
this._initializeView(); | |
if (this._activeItem && this._activeItem.watchId === watchId) { | |
return Promise.resolve(); | |
} | |
const model = this.model; | |
const index = this._index; | |
return this._thumbInfoLoader.load(watchId).then(info => { | |
info.id = info.isChannel ? info.id : watchId; | |
const item = VideoListItem.createByThumbInfo(info); | |
model.insertItem(item, index + 1); | |
this._refreshIndex(true); | |
this.emit('update'); | |
this.emit('command', 'notifyHtml', | |
`次に再生: <img src="${item.thumbnail}" style="width: 96px;">${textUtil.escapeToZenkaku(item.title)}` | |
); | |
}).catch(result => { | |
const item = VideoListItem.createBlankInfo(watchId); | |
model.insertItem(item, index + 1); | |
this._refreshIndex(true); | |
this.emit('update'); | |
window.console.error(result); | |
this.emit('command', 'alert', `動画情報の取得に失敗: ${watchId}`); | |
}); | |
} | |
insertCurrentVideo(videoInfo) { | |
this._initializeView(); | |
if (this._activeItem && | |
!this._activeItem.isBlankData && | |
this._activeItem.watchId === videoInfo.watchId) { | |
this._activeItem.updateByVideoInfo(videoInfo); | |
this._activeItem.isPlayed = true; | |
this.scrollToActiveItem(); | |
return; | |
} | |
let currentItem = this.model.findByWatchId(videoInfo.watchId); | |
if (currentItem && !currentItem.isBlankData) { | |
currentItem.updateByVideoInfo(videoInfo); | |
currentItem.isPlayed = true; | |
this.setIndex(this.model.indexOf(currentItem)); | |
this.scrollToActiveItem(); | |
return; | |
} | |
const item = VideoListItem.createByVideoInfoModel(videoInfo); | |
item.isPlayed = true; | |
if (this._activeItem) { | |
this._activeItem.isActive = false; | |
} | |
this.model.insertItem(item, this._index + 1); | |
this._activeItem = this.model.findByItemId(item.itemId); | |
this._refreshIndex(true); | |
} | |
removeItemByWatchId(watchId) { | |
const item = this.model.findByWatchId(watchId); | |
if (!item || item.isActive) { | |
return; | |
} | |
this.model.removeItem(item); | |
this._refreshIndex(true); | |
} | |
append(watchId) { | |
this._initializeView(); | |
if (this._activeItem && this._activeItem.watchId === watchId) { | |
return Promise.resolve(); | |
} | |
const model = this.model; | |
return this._thumbInfoLoader.load(watchId).then(info => { | |
info.id = watchId; | |
const item = VideoListItem.createByThumbInfo(info); | |
model.appendItem(item); | |
this._refreshIndex(); | |
this.emit('update'); | |
this.emit('command', 'notifyHtml', | |
`リストの末尾に追加: <img src="${item.thumbnail}" style="width: 96px;">${textUtil.escapeToZenkaku(item.title)}` | |
); | |
}).catch(result => { | |
const item = VideoListItem.createBlankInfo(watchId); | |
model.appendItem(item); | |
this._refreshIndex(true); | |
this._refreshIndex(); | |
window.console.error(result); | |
this.emit('command', 'alert', '動画情報の取得に失敗: ' + watchId); | |
}); | |
} | |
getIndex() { | |
return this._activeItem ? this._index : -1; | |
} | |
setIndex(v, force) { | |
v = parseInt(v, 10); | |
if (this._index !== v || force) { | |
this._index = v; | |
if (this._activeItem) { | |
this._activeItem.isActive = false; | |
} | |
this._activeItem = this.model.getItemByIndex(v); | |
if (this._activeItem) { | |
this._activeItem.isActive = true; | |
} | |
this.emit('update'); | |
} | |
} | |
_refreshIndex(scrollToActive) { | |
this.setIndex(this.model.indexOf(this._activeItem), true); | |
if (scrollToActive) { | |
setTimeout(() => this.scrollToActiveItem(true), 1000); | |
} | |
} | |
_setIndexByItemId(itemId) { | |
const item = this.model.findByItemId(itemId); | |
if (item) { | |
this._setIndexByItem(item); | |
} | |
} | |
_setIndexByItem(item) { | |
const index = this.model.indexOf(item); | |
if (index >= 0) { | |
this.setIndex(index); | |
} | |
} | |
toggleEnable(v) { | |
if (!_.isBoolean(v)) { | |
this._isEnable = !this._isEnable; | |
this.emit('update'); | |
return; | |
} | |
if (this._isEnable !== v) { | |
this._isEnable = v; | |
this.emit('update'); | |
} | |
} | |
toggleLoop() { | |
this._isLoop = !this._isLoop; | |
this.emit('update'); | |
} | |
shuffle() { | |
this.model.shuffle(); | |
if (this._activeItem) { | |
this.model.removeItem(this._activeItem); | |
this.model.insertItem(this._activeItem, 0); | |
this.setIndex(0); | |
} else { | |
this.setIndex(-1); | |
} | |
this.view.scrollTop(0); | |
} | |
sortBy(key, isDesc) { | |
this.model.sortBy(key, isDesc); | |
this._refreshIndex(true); | |
setTimeout(() => { | |
this.view.scrollToItem(this._activeItem); | |
}, 1000); | |
} | |
removePlayedItem() { | |
this.model.removePlayedItem(); | |
this._refreshIndex(true); | |
setTimeout(() => this.view.scrollToItem(this._activeItem), 1000); | |
} | |
removeNonActiveItem() { | |
this.model.removeNonActiveItem(); | |
this._refreshIndex(true); | |
this.toggleEnable(false); | |
} | |
selectNext() { | |
if (!this.hasNext) { | |
return null; | |
} | |
const index = this.getIndex(); | |
const len = this.length; | |
if (len < 1) { | |
return null; | |
} | |
if (index < -1) { | |
this.setIndex(0); | |
} else if (index + 1 < len) { | |
this.setIndex(index + 1); | |
} else if (this.isLoop) { | |
this.setIndex((index + 1) % len); | |
} | |
return this._activeItem ? this._activeItem.watchId : null; | |
} | |
selectPrevious() { | |
const index = this.getIndex(); | |
const len = this.length; | |
if (len < 1) { | |
return null; | |
} | |
if (index < -1) { | |
this.setIndex(0); | |
} else if (index > 0) { | |
this.setIndex(index - 1); | |
} else if (this.isLoop) { | |
this.setIndex((index + len - 1) % len); | |
} else { | |
return null; | |
} | |
return this._activeItem ? this._activeItem.watchId : null; | |
} | |
scrollToActiveItem(force) { | |
if (this._activeItem && (force || !this.view.hasFocus)) { | |
this.view.scrollToItem(this._activeItem, force); | |
} | |
} | |
scrollToWatchId(watchId) { | |
const item = this.model.findByWatchId(watchId); | |
if (item) { | |
this.view.scrollToItem(item); | |
} | |
} | |
findByWatchId(watchId) { | |
return this.model.findByWatchId(watchId); | |
} | |
get isEnable() { | |
return this._isEnable; | |
} | |
get isLoop() { | |
return this._isLoop; | |
} | |
get length() { | |
return this.model.length; | |
} | |
get hasNext() { | |
const len = this.length; | |
return len > 0 && (this.isLoop || this._index < len - 1); | |
} | |
} | |
const MediaSessionApi = (() => { | |
const emitter = new Emitter(); | |
let init = false; | |
const update = ( | |
{title, artist, album, artwork, duration} = {title: '', artist: '', album: '', artwork: [], duration: 1}) => { | |
if (!('mediaSession' in navigator)) { | |
return; | |
} | |
//navigator.mediaSession.metadata = new self.MediaMetadata({ | |
// title, | |
// artist, | |
// album, | |
// artwork | |
//}); | |
const nm = navigator.mediaSession; | |
if ('setPositionState' in nm) { | |
nm.setPositionState({duration}); | |
} | |
if (init) { | |
return; | |
} | |
init = true; | |
nm.setActionHandler('play', () => emitter.emit('command', 'play')); | |
nm.setActionHandler('pause', () => emitter.emit('command', 'pause')); | |
nm.setActionHandler('seekbackward', () => emitter.emit('command', 'seekBy', -5)); | |
nm.setActionHandler('seekforward', () => emitter.emit('command', 'seekBy', +5)); | |
nm.setActionHandler('previoustrack', () => emitter.emit('command', 'playPreviousVideo')); | |
nm.setActionHandler('nexttrack', () => emitter.emit('command', 'playNextVideo')); | |
nm.setActionHandler('stop', () => emitter.emit('command', 'close')); | |
nm.setActionHandler('seekto', e => { | |
emitter.emit('command', 'seekTo', e.seekTime); | |
}); | |
}; | |
const updateByVideoInfo = videoInfo => { | |
const title = videoInfo.title; | |
const artist = videoInfo.owner.name; | |
const album = ''; | |
const artwork = [{src: videoInfo.thumbnail, sizes: '130x100', type: 'image/jpg'}]; | |
if (videoInfo.betterThumbnail) { | |
artwork.push({src: videoInfo.betterThumbnail, sizes: '320x270', type: 'image/jpg'}); | |
} | |
if (videoInfo.largeThumbnail) { | |
artwork.push({src: videoInfo.largeThumbnail, sizes: '1280x720', type: 'image/jpg'}); | |
} | |
update({title, artist, album, artwork, duration: videoInfo.duration}); | |
}; | |
const updatePositionState = ({duration, playbackRate, currentTime}) => { | |
const nm = navigator.mediaSession; | |
if (!('setPositionState' in nm)) { | |
return; | |
} | |
nm.setPositionState({duration, playbackRate, currentTime}); | |
}; | |
const updatePositionStateByMedia = media => { | |
updatePositionState({ | |
duration: media.duration, | |
playbackRate: media.playbackRate, | |
currentTime: media.currentTime | |
}); | |
}; | |
return { | |
onCommand: callback => emitter.on('command', callback), | |
update, | |
updateByVideoInfo, | |
updatePositionState, | |
updatePositionStateByMedia | |
}; | |
})(); | |
const NVApi = { | |
FRONT_ID: '6', | |
FRONT_VER:'0', | |
REQUEST_WITH: 'https://www.nicovideo.jp', | |
call: (url, params = {}) => { | |
return netUtil | |
.fetch(url, { | |
mode: 'cors', | |
credentials: 'include', | |
timeout: 5000, | |
method: params.method || 'GET', | |
headers: { | |
'X-Frontend-Id': NVApi.FRONT_ID, | |
'X-Frontend-Version': NVApi.FRONT_VER, | |
'X-Request-with': NVApi.REQUEST_WITH | |
} | |
}) | |
.catch(err => console.warn('nvapi fail', {err, url, params})); | |
} | |
}; | |
const LikeApi = { | |
call: (videoId, method = 'POST') => { | |
const api = 'https://nvapi.nicovideo.jp/v1/users/me/likes/items'; | |
const url = `${api}?videoId=${videoId}`; | |
return NVApi.call(url, {method}).then(e => e.json()); | |
}, | |
like: videoId => LikeApi.call(videoId, 'POST'), | |
unlike: videoId => LikeApi.call(videoId, 'DELETE') | |
}; | |
class PlayerConfig { | |
static getInstance(config) { | |
if (!PlayerConfig.instance) { | |
PlayerConfig.instance = this.wrapKey(config); | |
} | |
return PlayerConfig.instance; | |
} | |
static wrapKey(config, mode = '') { | |
if (!mode && util.isGinzaWatchUrl()) { | |
mode = 'ginza'; | |
} else if (location && location.host.indexOf('.nicovideo.jp') < 0) { | |
mode = 'others'; | |
} | |
if (!mode) { return config; } | |
config.getNativeKey = key => { | |
switch(mode) { | |
case 'ginza': | |
if (['autoPlay', 'screenMode'].includes(key)) { | |
return `${key}:${mode}`; | |
} | |
break; | |
case 'others': | |
if (['autoPlay', 'screenMode', 'overrideWatchLink'].includes(key)) { | |
return `${key}:${mode}`; | |
} | |
break; | |
} | |
return key; | |
}; | |
return config; | |
} | |
} | |
class VideoWatchOptions { | |
constructor(watchId, options, config) { | |
this._watchId = watchId; | |
this._options = options || {}; | |
this._config = config; | |
} | |
get rawData() { | |
return this._options; | |
} | |
get eventType() { | |
return this._options.eventType || ''; | |
} | |
get query() { | |
return this._options.query || {}; | |
} | |
get videoLoadOptions() { | |
let options = { | |
economy: this.isEconomySelected | |
}; | |
return options; | |
} | |
get mylistLoadOptions() { | |
let options = {}; | |
let query = this.query; | |
if (query.mylist_sort) { | |
options.sort = query.mylist_sort; | |
} | |
options.group_id = query.group_id; | |
options.watchId = this._watchId; | |
return options; | |
} | |
get isPlaylistStartRequest() { | |
let eventType = this.eventType; | |
let query = this.query; | |
if (eventType !== 'click' || query.continuous !== '1') { | |
return false; | |
} | |
if (['mylist', 'deflist', 'tag', 'search'].includes(query.playlist_type) && | |
(query.group_id || query.order)) { | |
return true; | |
} | |
return false; | |
} | |
hasKey(key) { | |
return _.has(this._options, key); | |
} | |
get isOpenNow() { | |
return this._options.openNow === true; | |
} | |
get isEconomySelected() { | |
return _.isBoolean(this._options.economy) ? | |
this._options.economy : this._config.getValue('smileVideoQuality') === 'eco'; | |
} | |
get isAutoCloseFullScreen() { | |
return !!this._options.autoCloseFullScreen; | |
} | |
get isReload() { | |
return this._options.reloadCount > 0; | |
} | |
get videoServerType() { | |
return this._options.videoServerType; | |
} | |
get isAutoZenTubeDisabled() { | |
return !!this._options.isAutoZenTubeDisabled; | |
} | |
get reloadCount() { | |
return this._options.reloadCount; | |
} | |
get currentTime() { | |
return _.isNumber(this._options.currentTime) ? | |
parseFloat(this._options.currentTime, 10) : 0; | |
} | |
createForVideoChange(options) { | |
options = options || {}; | |
delete this._options.economy; | |
_.defaults(options, this._options); | |
options.openNow = true; | |
delete options.videoServerType; | |
options.isAutoZenTubeDisabled = false; | |
options.currentTime = 0; | |
options.reloadCount = 0; | |
options.query = {}; | |
return options; | |
} | |
createForReload(options) { | |
options = options || {}; | |
delete this._options.economy; | |
options.isAutoZenTubeDisabled = typeof options.isAutoZenTubeDisabled === 'boolean' ? | |
options.isAutoZenTubeDisabled : true; | |
_.defaults(options, this._options); | |
options.openNow = true; | |
options.reloadCount = options.reloadCount ? (options.reloadCount + 1) : 1; | |
options.query = {}; | |
return options; | |
} | |
createForSession(options) { | |
options = options || {}; | |
_.defaults(options, this._options); | |
options.query = {}; | |
return options; | |
} | |
} | |
class NicoVideoPlayerDialogView extends Emitter { | |
constructor(...args) { | |
super(); | |
this.initialize(...args); | |
} | |
initialize(params) { | |
const dialog = this._dialog = params.dialog; | |
this._playerConfig = params.playerConfig; | |
this._nicoVideoPlayer = params.nicoVideoPlayer; | |
this._state = params.playerState; | |
this._currentTimeGetter = params.currentTimeGetter; | |
this._aspectRatio = 9 / 16; | |
dialog.on('canPlay', this._onVideoCanPlay.bind(this)); | |
dialog.on('videoCount', this._onVideoCount.bind(this)); | |
dialog.on('error', this._onVideoError.bind(this)); | |
dialog.on('play', this._onVideoPlay.bind(this)); | |
dialog.on('playing', this._onVideoPlaying.bind(this)); | |
dialog.on('pause', this._onVideoPause.bind(this)); | |
dialog.on('stalled', this._onVideoStalled.bind(this)); | |
dialog.on('abort', this._onVideoAbort.bind(this)); | |
dialog.on('aspectRatioFix', this._onVideoAspectRatioFix.bind(this)); | |
dialog.on('volumeChange', this._onVolumeChange.bind(this)); | |
dialog.on('volumeChangeEnd', this._onVolumeChangeEnd.bind(this)); | |
dialog.on('beforeVideoOpen', this._onBeforeVideoOpen.bind(this)); | |
dialog.on('loadVideoInfoFail', this._onVideoInfoFail.bind(this)); | |
dialog.on('videoServerType', this._onVideoServerType.bind(this)); | |
this._initializeDom(); | |
this._state.on('update', this._onPlayerStateUpdate.bind(this)); | |
this._state.onkey('videoInfo', this._onVideoInfoLoad.bind(this)); | |
} | |
async _initializeDom() { | |
util.addStyle(NicoVideoPlayerDialogView.__css__); | |
const $dialog = this._$dialog = util.$.html(NicoVideoPlayerDialogView.__tpl__.trim()); | |
const onCommand = this._onCommand.bind(this); | |
const config = this._playerConfig; | |
const state = this._state; | |
this._$body = util.$('body, html'); | |
const $container = this._$playerContainer = $dialog.find('.zenzaPlayerContainer'); | |
const container = $container[0]; | |
const classList = this.classList = ClassList(container); | |
container.addEventListener('click', e => { | |
global.emitter.emitAsync('hideHover'); | |
if ( | |
e.target.classList.contains('touchWrapper') && | |
config.props.enableTogglePlayOnClick && | |
!classList.contains('menuOpen')) { | |
onCommand('togglePlay'); | |
} | |
e.preventDefault(); | |
e.stopPropagation(); | |
classList.remove('menuOpen'); | |
}); | |
container.addEventListener('command', e=> { | |
e.stopPropagation(); | |
e.preventDefault(); | |
this._onCommand(e.detail.command, e.detail.param); | |
}); | |
container.addEventListener('focusin', e => { | |
const target = (e.path && e.path.length) ? e.path[0] : e.target; | |
if (target.dataset.hasSubmenu) { | |
classList.add('menuOpen'); | |
} | |
}); | |
this._applyState(); | |
let lastX = 0, lastY = 0; | |
let onMouseMove = this._onMouseMove.bind(this); | |
let onMouseMoveEnd = _.debounce(this._onMouseMoveEnd.bind(this), 400); | |
container.addEventListener('mousemove', _.throttle(e => { | |
if (e.buttons === 0 && lastX === e.screenX && lastY === e.screenY) { | |
return; | |
} | |
lastX = e.screenX; | |
lastY = e.screenY; | |
onMouseMove(e); | |
onMouseMoveEnd(e); | |
}, 100)); | |
$dialog | |
.on('dblclick', e => { | |
if (!e.target || e.target.id !== 'zenzaVideoPlayerDialog') { | |
return; | |
} | |
if (config.props.enableDblclickClose) { | |
this.emit('command', 'close'); | |
} | |
}) | |
.toggleClass('is-guest', !util.isLogin()); | |
this.hoverMenu = new VideoHoverMenu({ | |
playerContainer: container, | |
playerState: state | |
}); | |
this.commentInput = new CommentInputPanel({ | |
$playerContainer: $container, | |
playerConfig: config | |
}); | |
this.commentInput.on('post', (e, chat, cmd) => | |
this.emit('postChat', e, chat, cmd)); | |
let hasPlaying = false; | |
this.commentInput.on('focus', isAutoPause => { | |
hasPlaying = state.isPlaying; | |
if (isAutoPause) { | |
this.emit('command', 'pause'); | |
} | |
}); | |
this.commentInput.on('blur', isAutoPause => { | |
if (isAutoPause && hasPlaying && state.isOpen) { | |
this.emit('command', 'play'); | |
} | |
}); | |
this.commentInput.on('esc', () => this._escBlockExpiredAt = Date.now() + 1000 * 2); | |
await sleep.idle(); | |
this.videoControlBar = new VideoControlBar({ | |
$playerContainer: $container, | |
playerConfig: config, | |
player: this._dialog, | |
playerState: this._state, | |
currentTimeGetter: this._currentTimeGetter | |
}); | |
this.videoControlBar.on('command', onCommand); | |
this._$errorMessageContainer = $container.find('.errorMessageContainer'); | |
await sleep.idle(); | |
this._initializeVideoInfoPanel(); | |
this._initializeResponsive(); | |
this.selectTab(this._state.currentTab); | |
document.documentElement.addEventListener('paste', this._onPaste.bind(this)); | |
global.emitter.on('showMenu', () => this.addClass('menuOpen')); | |
global.emitter.on('hideMenu', () => this.removeClass('menuOpen')); | |
global.emitter.on('fullscreenStatusChange', () => this._applyScreenMode(true)); | |
document.body.append($dialog[0]); | |
this.emitResolve('dom-ready'); | |
} | |
_initializeVideoInfoPanel() { | |
if (this.videoInfoPanel) { | |
return this.videoInfoPanel; | |
} | |
this.videoInfoPanel = new VideoInfoPanel({ | |
dialog: this, | |
node: this._$playerContainer | |
}); | |
this.videoInfoPanel.on('command', this._onCommand.bind(this)); | |
return this.videoInfoPanel; | |
} | |
_onCommand(command, param) { | |
switch (command) { | |
case 'settingPanel': | |
this.toggleSettingPanel(); | |
break; | |
case 'toggle-flipH': | |
this.toggleClass('is-flipH'); | |
break; | |
case 'toggle-flipV': | |
this.toggleClass('is-flipV'); | |
break; | |
default: | |
this.emit('command', command, param); | |
} | |
} | |
async _onPaste(e) { | |
const isZen = !!e.target.closest('.zenzaVideoPlayerDialog'); | |
const target = (e.path && e.path[0]) ? e.path[0] : e.target; | |
window.console.log('onPaste', {e, target, isZen}); | |
if (!isZen && ['INPUT', 'TEXTAREA'].includes(target.tagName)) { | |
return; | |
} | |
let text; | |
try { text = await navigator.clipboard.readText(); } | |
catch(err) { | |
window.console.warn(err, navigator.clipboard); | |
text = e.clipboardData.getData('text/plain'); | |
} | |
if (!text) { | |
return; | |
} | |
text = text.trim(); | |
const isOpen = this._state.isOpen; | |
const watchIdReg = /((nm|sm|so)\d+)/.exec(text); | |
if (watchIdReg) { | |
return this._onCommand('open', watchIdReg[1]); | |
} | |
if (!isOpen) { | |
return; | |
} | |
const youtubeReg = /^https?:\/\/((www\.|)youtube\.com\/watch|youtu\.be)/.exec(text); | |
if (youtubeReg) { | |
return this._onCommand('setVideo', text); | |
} | |
const seekReg = /^(\d+):(\d+)$/.exec(text); | |
if (seekReg) { | |
return this._onCommand('seek', seekReg[1] * 60 + seekReg[2] * 1); | |
} | |
const mylistReg = /mylist(\/#\/|\/)(\d+)/.exec(text); | |
if (mylistReg) { | |
return this._onCommand('playlistSetMylist', mylistReg[2]); | |
} | |
const ownerReg = /user\/(\d+)/.exec(text); | |
if (ownerReg) { | |
return this._onCommand('playlistSetUploadedVideo', ownerReg[1]); | |
} | |
} | |
_initializeResponsive() { | |
window.addEventListener('resize', _.debounce(this._updateResponsive.bind(this), 500)); | |
this.varMapper = new VariablesMapper({config: this._playerConfig}); | |
this.varMapper.on('update', () => this._updateResponsive()); | |
} | |
_updateResponsive() { | |
if (!this._state.isOpen) { | |
return; | |
} | |
const $container = this._$playerContainer; | |
const [header] = $container.find('.zenzaWatchVideoHeaderPanel'); | |
const config = this._playerConfig; | |
const update = () => { | |
const w = global.innerWidth, h = global.innerHeight; | |
const vMargin = h - w * this._aspectRatio; | |
const controlBarMode = config.props.fullscreenControlBarMode; | |
if (controlBarMode === 'always-hide') { | |
this.toggleClass('showVideoControlBar', false); | |
return; | |
} | |
const videoControlBarHeight = this.varMapper.videoControlBarHeight; | |
const showVideoHeaderPanel = vMargin >= videoControlBarHeight + header.offsetHeight * 2; | |
let showVideoControlBar; | |
switch (controlBarMode) { | |
case 'always-show': | |
showVideoControlBar = true; | |
break; | |
case 'auto': | |
default: | |
showVideoControlBar = vMargin >= videoControlBarHeight; | |
} | |
this.toggleClass('showVideoControlBar', showVideoControlBar); | |
this.toggleClass('showVideoHeaderPanel', showVideoHeaderPanel); | |
}; | |
update(); | |
} | |
_onMouseMove() { | |
if (this._isMouseMoving) { | |
return; | |
} | |
this.addClass('is-mouseMoving'); | |
this._isMouseMoving = true; | |
} | |
_onMouseMoveEnd() { | |
if (!this._isMouseMoving) { | |
return; | |
} | |
this.removeClass('is-mouseMoving'); | |
this._isMouseMoving = false; | |
} | |
_onVideoCanPlay(watchId, videoInfo, options) { | |
this.emit('canPlay', watchId, videoInfo, options); | |
} | |
_onVideoCount({comment, view, mylist} = {}) { | |
this.emit('videoCount', {comment, view, mylist}); | |
} | |
_onVideoError(e) { | |
this.emit('error', e); | |
} | |
_onBeforeVideoOpen() { | |
this._setThumbnail(); | |
} | |
_onVideoInfoLoad(videoInfo) { | |
this.videoInfoPanel.update(videoInfo); | |
} | |
_onVideoInfoFail(videoInfo) { | |
if (videoInfo) { | |
this.videoInfoPanel.update(videoInfo); | |
} | |
} | |
_onVideoServerType(type, sessionInfo) { | |
this.toggleClass('is-dmcPlaying', type === 'dmc'); | |
this.emit('videoServerType', type, sessionInfo); | |
} | |
_onVideoPlay() { | |
} | |
_onVideoPlaying() { | |
} | |
_onVideoPause() { | |
} | |
_onVideoStalled() { | |
} | |
_onVideoAbort() { | |
} | |
_onVideoAspectRatioFix(ratio) { | |
this._aspectRatio = ratio; | |
this._updateResponsive(); | |
} | |
_onVolumeChange(/*vol, mute*/) { | |
this.addClass('volumeChanging'); | |
} | |
_onVolumeChangeEnd(/*vol, mute*/) { | |
this.removeClass('volumeChanging'); | |
} | |
_onScreenModeChange() { | |
this._applyScreenMode(); | |
} | |
_getStateClassNameTable() { | |
return this._classNameTable = this._classNameTable || objUtil.toMap({ | |
isAbort: 'is-abort', | |
isBackComment: 'is-backComment', | |
isShowComment: 'is-showComment', | |
isDebug: 'is-debug', | |
isDmcAvailable: 'is-dmcAvailable', | |
isDmcPlaying: 'is-dmcPlaying', | |
isError: 'is-error', | |
isLoading: 'is-loading', | |
isMute: 'is-mute', | |
isLoop: 'is-loop', | |
isOpen: 'is-open', | |
isPlaying: 'is-playing', | |
isSeeking: 'is-seeking', | |
isPausing: 'is-pausing', | |
isLiked: 'is-liked', | |
isChanging: 'is-changing', | |
isUpdatingDeflist: 'is-updatingDeflist', | |
isUpdatingMylist: 'is-updatingMylist', | |
isPlaylistEnable: 'is-playlistEnable', | |
isCommentPosting: 'is-commentPosting', | |
isRegularUser: 'is-regularUser', | |
isWaybackMode: 'is-waybackMode', | |
isNotPlayed: 'is-notPlayed', | |
isYouTube: 'is-youTube' | |
}); | |
} | |
_onPlayerStateChange(changedState) { | |
for (const key of changedState.keys()) { | |
this._onPlayerStateUpdate(key, changedState.get(key)); | |
} | |
} | |
_onPlayerStateUpdate(key, value) { | |
switch (key) { | |
case 'thumbnail': | |
return this._setThumbnail(value); | |
case 'screenMode': | |
case 'isOpen': | |
if (this._state.isOpen) { | |
this.show(); | |
this._onScreenModeChange(); | |
} else { | |
this.hide(); | |
} | |
return; | |
case 'errorMessage': | |
return this._$errorMessageContainer[0].textContent = value; | |
case 'currentTab': | |
return this.selectTab(value); | |
} | |
const table = this._getStateClassNameTable(); | |
const className = table.get(key); | |
if (className) { | |
this.toggleClass(className, !!value); | |
} | |
} | |
_applyState() { | |
const table = this._getStateClassNameTable(); | |
const state = this._state; | |
for (const [key, className] of table) { | |
this.classList.toggle(className, state[key]); | |
} | |
if (this._state.isOpen) { | |
this._applyScreenMode(); | |
} | |
} | |
_getScreenModeClassNameTable() { | |
return [ | |
'zenzaScreenMode_3D', | |
'zenzaScreenMode_small', | |
'zenzaScreenMode_sideView', | |
'zenzaScreenMode_normal', | |
'zenzaScreenMode_big', | |
'zenzaScreenMode_wide' | |
]; | |
} | |
_applyScreenMode(force = false) { | |
const screenMode = `zenzaScreenMode_${this._state.screenMode}`; | |
if (!force && this._lastScreenMode === screenMode) { return; } | |
this._lastScreenMode = screenMode; | |
const modes = this._getScreenModeClassNameTable(); | |
const isFull = util.fullscreen.now(); | |
Object.assign(document.body.dataset, { | |
screenMode: this._state.screenMode, | |
fullscreen: isFull ? 'yes' : 'no' | |
}); | |
modes.forEach(m => this._$body.raf.toggleClass(m, m === screenMode && !isFull)); | |
this._updateScreenModeStyle(); | |
} | |
_updateScreenModeStyle() { | |
if (!this._state.isOpen) { | |
util.StyleSwitcher.update({off: 'style.screenMode'}); | |
return; | |
} | |
if (Fullscreen.now()) { | |
util.StyleSwitcher.update({ | |
on: 'style.screenMode.for-full, style.screenMode.for-screen-full', | |
off: 'style.screenMode:not(.for-full):not(.for-screen-full), link[href*="watch.css"]' | |
}); | |
return; | |
} | |
let on, off; | |
switch (this._state.screenMode) { | |
case '3D': | |
case 'wide': | |
on = 'style.screenMode.for-full, style.screenMode.for-window-full'; | |
off = 'style.screenMode:not(.for-full):not(.for-window-full), link[href*="watch.css"]'; | |
break; | |
default: | |
case 'normal': | |
case 'big': | |
on = 'style.screenMode.for-dialog, style.screenMode.for-big, style.screenMode.for-normal, link[href*="watch.css"]'; | |
off = 'style.screenMode:not(.for-dialog):not(.for-big):not(.for-normal)'; | |
break; | |
case 'small': | |
case 'sideView': | |
on = 'style.screenMode.for-popup, style.screenMode.for-sideView, .style.screenMode.for-small, link[href*="watch.css"]'; | |
off = 'style.screenMode:not(.for-popup):not(.for-sideView):not(.for-small)'; | |
break; | |
} | |
util.StyleSwitcher.update({on, off}); | |
} | |
show() { | |
ClassList(this._$dialog[0]).add('is-open'); | |
if (!Fullscreen.now()) { | |
ClassList(document.body).remove('fullscreen'); | |
} | |
this._$body.raf.addClass('showNicoVideoPlayerDialog'); | |
util.StyleSwitcher.update({on: 'style.zenza-open'}); | |
this._updateScreenModeStyle(); | |
} | |
hide() { | |
ClassList(this._$dialog[0]).remove('is-open'); | |
this.settingPanel && this.settingPanel.close(); | |
this._$body.raf.removeClass('showNicoVideoPlayerDialog'); | |
util.StyleSwitcher.update({off: 'style.zenza-open, style.screenMode', on: 'link[href*="watch.css"]'}); | |
this._clearClass(); | |
} | |
_clearClass() { | |
const modes = this._getScreenModeClassNameTable().join(' '); | |
this._lastScreenMode = ''; | |
this._$body.raf.removeClass(modes); | |
} | |
_setThumbnail(thumbnail) { | |
if (thumbnail) { | |
this.css('background-image', `url(${thumbnail})`); | |
} else { | |
this.css('background-image', `url(${CONSTANT.BLANK_PNG})`); | |
} | |
} | |
focusToCommentInput() { | |
window.setTimeout(() => this.commentInput.focus(), 0); | |
} | |
toggleSettingPanel() { | |
if (!this.settingPanel) { | |
this.settingPanel = document.createElement('zenza-setting-panel'); | |
this.settingPanel.config = this._playerConfig; | |
this._$playerContainer.append(this.settingPanel); | |
} | |
this.settingPanel.toggle(); | |
} | |
get$Container() { | |
return this._$playerContainer; | |
} | |
css(key, val) { | |
this._$playerContainer.raf.css(key, val); | |
} | |
addClass(name) { | |
return this.classList.add(name); | |
} | |
removeClass(name) { | |
return this.classList.remove(name); | |
} | |
toggleClass(name, v) { | |
this.classList.toggle(name, v); | |
} | |
hasClass(name) { | |
return this.classList.contains(name); | |
} | |
appendTab(name, title) { | |
return this.videoInfoPanel.appendTab(name, title); | |
} | |
selectTab(name) { | |
this._playerConfig.props.videoInfoPanelTab = name; | |
this._state.currentTab = name; | |
this.videoInfoPanel.selectTab(name); | |
global.emitter.emit('tabChange', name); | |
} | |
execCommand(command, param) { | |
this.emit('command', command, param); | |
} | |
blinkTab(name) { | |
this.videoInfoPanel.blinkTab(name); | |
} | |
clearPanel() { | |
this.videoInfoPanel.clear(); | |
} | |
} | |
util.addStyle(` | |
.is-watch .BaseLayout { | |
display: none; | |
} | |
#zenzaVideoPlayerDialog { | |
touch-action: manipulation; /* for Safari */ | |
touch-action: none; | |
} | |
#zenzaVideoPlayerDialog::before { | |
display: none; | |
} | |
.zenzaPlayerContainer { | |
left: 0 !important; | |
top: 0 !important; | |
width: 100vw !important; | |
height: 100vh !important; | |
contain: size layout; | |
} | |
.videoPlayer, | |
.commentLayerFrame, | |
.resizeObserver { | |
top: 0 !important; | |
left: 0 !important; | |
width: 100vw !important; | |
height: 100% !important; | |
right: 0 !important; | |
border: 0 !important; | |
z-index: 100 !important; | |
contain: layout style size paint; | |
will-change: transform,opacity; | |
} | |
.resizeObserver { | |
z-index: -1; | |
opacity: 0; | |
pointer-events: none; | |
} | |
.is-open .videoPlayer>* { | |
cursor: none; | |
} | |
.showVideoControlBar { | |
--padding-bottom: ${VideoControlBar.BASE_HEIGHT}px; | |
--padding-bottom: var(--zenza-control-bar-height); | |
} | |
.zenzaStoryboardOpen .showVideoControlBar { | |
--padding-bottom: calc(var(--zenza-control-bar-height) + 80px); | |
} | |
.zenzaStoryboardOpen.is-fullscreen .showVideoControlBar { | |
--padding-bottom: calc(var(--zenza-control-bar-height) + 50px); | |
} | |
.showVideoControlBar .videoPlayer, | |
.showVideoControlBar .commentLayerFrame, | |
.showVideoControlBar .resizeObserver { | |
height: calc(100% - var(--padding-bottom)) !important; | |
} | |
.showVideoControlBar .videoPlayer { | |
z-index: 100 !important; | |
} | |
.showVideoControlBar .commentLayerFrame { | |
z-index: 101 !important; | |
} | |
.is-showComment.is-backComment .videoPlayer | |
{ | |
top: 25% !important; | |
left: 25% !important; | |
width: 50% !important; | |
height: 50% !important; | |
right: 0 !important; | |
bottom: 0 !important; | |
border: 0 !important; | |
z-index: 102 !important; | |
} | |
body[data-screen-mode="3D"] .zenzaPlayerContainer .videoPlayer { | |
transform: perspective(700px) rotateX(10deg); | |
margin-top: -5%; | |
} | |
.zenzaPlayerContainer { | |
left: 0; | |
width: 100vw; | |
height: 100vh; | |
box-shadow: none; | |
} | |
.is-backComment .videoPlayer { | |
left: 25%; | |
top: 25%; | |
width: 50%; | |
height: 50%; | |
z-index: 102; | |
} | |
body[data-screen-mode="3D"] .zenzaPlayerContainer .videoPlayer { | |
transform: perspective(600px) rotateX(10deg); | |
height: 100%; | |
} | |
body[data-screen-mode="3D"] .zenzaPlayerContainer .commentLayerFrame { | |
transform: translateZ(0) perspective(600px) rotateY(30deg) rotateZ(-15deg) rotateX(15deg); | |
opacity: 0.9; | |
height: 100%; | |
margin-left: 20%; | |
} | |
`, {className: 'screenMode for-full', disabled: true}); | |
util.addStyle(` | |
body #zenzaVideoPlayerDialog { | |
contain: style size; | |
} | |
#zenzaVideoPlayerDialog::before { | |
display: none; | |
} | |
body.zenzaScreenMode_sideView { | |
--sideView-left-margin: ${CONSTANT.SIDE_PLAYER_WIDTH + 24}px; | |
--sideView-top-margin: 76px; | |
margin-left: var(--sideView-left-margin); | |
margin-top: var(--sideView-top-margin); | |
width: auto; | |
} | |
body.zenzaScreenMode_sideView.nofix { | |
--sideView-top-margin: 40px; | |
} | |
body.zenzaScreenMode_sideView:not(.nofix) #siteHeader { | |
width: auto; | |
} | |
body.zenzaScreenMode_sideView:not(.nofix) #siteHeader #siteHeaderInner { | |
width: auto; | |
} | |
.zenzaScreenMode_sideView .zenzaVideoPlayerDialog.is-open, | |
.zenzaScreenMode_small .zenzaVideoPlayerDialog.is-open { | |
display: block; | |
top: 0; left: 0; right: 100%; bottom: 100%; | |
} | |
.zenzaScreenMode_sideView .zenzaPlayerContainer, | |
.zenzaScreenMode_small .zenzaPlayerContainer { | |
width: ${CONSTANT.SIDE_PLAYER_WIDTH}px; | |
height: ${CONSTANT.SIDE_PLAYER_HEIGHT}px; | |
} | |
.is-open .zenzaVideoPlayerDialog { | |
contain: layout style size; | |
} | |
.zenzaVideoPlayerDialogInner { | |
top: 0; | |
left: 0; | |
transform: none; | |
} | |
@media screen and (min-width: 1432px) | |
{ | |
body.zenzaScreenMode_sideView { | |
--sideView-left-margin: calc(100vw - 1024px); | |
} | |
body.zenzaScreenMode_sideView:not(.nofix) #siteHeader { | |
width: calc(100vw - (100vw - 1024px)); | |
} | |
.zenzaScreenMode_sideView .zenzaPlayerContainer { | |
width: calc(100vw - 1024px); | |
height: calc((100vw - 1024px) * 9 / 16); | |
} | |
} | |
`, {className: 'screenMode for-popup', disabled: true}); | |
util.addStyle(` | |
body.zenzaScreenMode_sideView, | |
body.zenzaScreenMode_small { | |
border-bottom: 40px solid; | |
margin-top: 0; | |
} | |
`, {className: 'domain slack-com', disabled: true}); | |
util.addStyle(` | |
.zenzaScreenMode_normal .zenzaPlayerContainer .videoPlayer { | |
left: 2.38%; | |
width: 95.23%; | |
} | |
.zenzaScreenMode_big .zenzaPlayerContainer { | |
width: ${CONSTANT.BIG_PLAYER_WIDTH}px; | |
height: ${CONSTANT.BIG_PLAYER_HEIGHT}px; | |
} | |
`, {className: 'screenMode for-dialog', disabled: true}); | |
util.addStyle(` | |
.zenzaScreenMode_3D, | |
.zenzaScreenMode_normal, | |
.zenzaScreenMode_big, | |
.zenzaScreenMode_wide | |
{ | |
overflow-x: hidden !important; | |
overflow-y: hidden !important; | |
overflow: hidden !important; | |
} | |
/* | |
プレイヤーが動いてる間、裏の余計な物のマウスイベントを無効化 | |
多少軽量化が期待できる? | |
*/ | |
body.zenzaScreenMode_big >*:not(.zen-family) *, | |
body.zenzaScreenMode_normal >*:not(.zen-family) *, | |
body.zenzaScreenMode_wide >*:not(.zen-family) *, | |
body.zenzaScreenMode_3D >*:not(.zen-family) * { | |
pointer-events: none; | |
user-select: none; | |
animation-play-state: paused !important; | |
contain: style layout paint; | |
} | |
body.zenzaScreenMode_big .ZenButton, | |
body.zenzaScreenMode_normal .ZenButton, | |
body.zenzaScreenMode_wide .ZenButton, | |
body.zenzaScreenMode_3D .ZenButton { | |
display: none; | |
} | |
.ads, .banner, iframe[name^="ads"] { | |
visibility: hidden !important; | |
pointer-events: none; | |
} | |
.VideoThumbnailComment { | |
display: none !important; | |
} | |
/* 大百科の奴 */ | |
#scrollUp { | |
display: none !important; | |
} | |
.SeriesDetailContainer-backgroundInner { | |
background-image: none !important; | |
filter: none !important; | |
} | |
.Hidariue-image { | |
visibility: hidden !important; | |
} | |
`, {className: 'zenza-open', disabled: true}); | |
NicoVideoPlayerDialogView.__css__ = ` | |
.zenzaVideoPlayerDialog { | |
display: none; | |
position: fixed; | |
/*background: rgba(0, 0, 0, 0.8);*/ | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
z-index: ${CONSTANT.BASE_Z_INDEX}; | |
font-size: 13px; | |
text-align: left; | |
box-sizing: border-box; | |
contain: size style layout; | |
} | |
.zenzaVideoPlayerDialog::before { | |
content: ' '; | |
background: rgba(0, 0, 0, 0.8); | |
position: fixed; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
will-change: transform; | |
} | |
.is-regularUser .forPremium { | |
display: none !important; | |
} | |
.forDmc { | |
display: none; | |
} | |
.is-dmcPlaying .forDmc { | |
display: inherit; | |
} | |
.zenzaVideoPlayerDialog * { | |
box-sizing: border-box; | |
} | |
.zenzaVideoPlayerDialog.is-open { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
} | |
.zenzaVideoPlayerDialog li { | |
text-align: left; | |
} | |
.zenzaVideoPlayerDialogInner { | |
background: #000; | |
box-sizing: border-box; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 1}; | |
box-shadow: 4px 4px 4px #000; | |
} | |
.zenzaPlayerContainer { | |
position: relative; | |
background: #000; | |
width: 672px; | |
height: 384px; | |
background-size: cover; | |
background-repeat: no-repeat; | |
background-position: center center; | |
} | |
.zenzaPlayerContainer.is-loading { | |
cursor: wait; | |
} | |
.zenzaPlayerContainer:not(.is-loading):not(.is-error) { | |
background-image: none !important; | |
background: none !important; | |
} | |
.zenzaPlayerContainer.is-loading .videoPlayer, | |
.zenzaPlayerContainer.is-loading .commentLayerFrame, | |
.zenzaPlayerContainer.is-error .videoPlayer, | |
.zenzaPlayerContainer.is-error .commentLayerFrame { | |
display: none; | |
} | |
.zenzaPlayerContainer .videoPlayer { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
right: 0; | |
bottom: 0; | |
height: 100%; | |
border: 0; | |
z-index: 100; | |
background: #000; | |
will-change: transform, opacity; | |
user-select: none; | |
} | |
.is-mouseMoving .videoPlayer>* { | |
cursor: auto; | |
} | |
.is-loading .videoPlayer>* { | |
cursor: wait; | |
} | |
.zenzaPlayerContainer .commentLayerFrame { | |
position: absolute; | |
border: 0; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
width: 100%; | |
height: 100%; | |
z-index: 101; | |
pointer-events: none; | |
cursor: none; | |
user-select: none; | |
opacity: var(--zenza-comment-layer-opacity); | |
} | |
.zenzaPlayerContainer.is-backComment .commentLayerFrame { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100vw; | |
height: calc(100vh - 40px); | |
right: auto; | |
bottom: auto; | |
z-index: 1; | |
} | |
.is-showComment.is-backComment .videoPlayer { | |
opacity: 0.90; | |
} | |
.is-showComment.is-backComment .videoPlayer:hover { | |
opacity: 1; | |
} | |
.loadingMessageContainer { | |
display: none; | |
pointer-events: none; | |
} | |
.zenzaPlayerContainer.is-loading .loadingMessageContainer { | |
display: inline-block; | |
position: absolute; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 10000}; | |
right: 8px; | |
bottom: 8px; | |
font-size: 24px; | |
color: var(--base-fore-color); | |
text-shadow: 0 0 8px #003; | |
font-family: serif; | |
letter-spacing: 2px; | |
} | |
@keyframes spin { | |
0% { transform: rotate(0deg); } | |
100% { transform: rotate(-1800deg); } | |
} | |
.zenzaPlayerContainer.is-loading .loadingMessageContainer::before, | |
.zenzaPlayerContainer.is-loading .loadingMessageContainer::after { | |
display: inline-block; | |
text-align: center; | |
content: '${'\\00272A'}'; | |
font-size: 18px; | |
line-height: 24px; | |
animation-name: spin; | |
animation-iteration-count: infinite; | |
animation-duration: 5s; | |
animation-timing-function: linear; | |
} | |
.zenzaPlayerContainer.is-loading .loadingMessageContainer::after { | |
animation-direction: reverse; | |
} | |
.errorMessageContainer { | |
display: none; | |
pointer-events: none; | |
user-select: none; | |
} | |
.zenzaPlayerContainer.is-error .errorMessageContainer { | |
display: inline-block; | |
position: absolute; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 10000}; | |
top: 50%; | |
left: 50%; | |
padding: 8px 16px; | |
transform: translate(-50%, -50%); | |
background: rgba(255, 0, 0, 0.9); | |
font-size: 24px; | |
box-shadow: 8px 8px 4px rgba(128, 0, 0, 0.8); | |
white-space: nowrap; | |
} | |
.errorMessageContainer:empty { | |
display: none !important; | |
} | |
.popupMessageContainer { | |
top: 50px; | |
left: 50px; | |
z-index: 25000; | |
position: absolute; | |
pointer-events: none; | |
transform: translateZ(0); | |
user-select: none; | |
} | |
@media screen { | |
/* 右パネル分の幅がある時は右パネルを出す */ | |
@media (min-width: 992px) { | |
.zenzaScreenMode_normal .zenzaVideoPlayerDialogInner { | |
padding-right: ${CONSTANT.RIGHT_PANEL_WIDTH}px; | |
background: none; | |
} | |
} | |
@media (min-width: 1216px) { | |
.zenzaScreenMode_big .zenzaVideoPlayerDialogInner { | |
padding-right: ${CONSTANT.RIGHT_PANEL_WIDTH}px; | |
background: none; | |
} | |
} | |
/* 縦長モニター */ | |
@media | |
(max-width: 991px) and (min-height: 700px) | |
{ | |
.zenzaScreenMode_normal .zenzaVideoPlayerDialogInner { | |
padding-bottom: 240px; | |
background: none; | |
} | |
} | |
@media | |
(max-width: 1215px) and (min-height: 700px) | |
{ | |
.zenzaScreenMode_big .zenzaVideoPlayerDialogInner { | |
padding-bottom: 240px; | |
background: none; | |
} | |
} | |
/* 960x540 */ | |
@media | |
(min-width: 1328px) and (max-width: 1663px) and | |
(min-height: 700px) and (min-height: 899px) | |
{ | |
.zenzaScreenMode_big .zenzaPlayerContainer { | |
width: calc(960px * 1.05); | |
height: 540px; | |
} | |
} | |
/* 1152x648 */ | |
@media | |
(min-width: 1530px) and (min-height: 900px) | |
{ | |
.zenzaScreenMode_big .zenzaPlayerContainer { | |
width: calc(1152px * 1.05); | |
height: 648px; | |
} | |
} | |
/* 1280x720 */ | |
@media | |
(min-width: 1664px) and (min-height: 900px) | |
{ | |
.zenzaScreenMode_big .zenzaPlayerContainer { | |
width: calc(1280px * 1.05); | |
height: 720px; | |
} | |
} | |
/* 1920x1080 */ | |
@media | |
(min-width: 2336px) and (min-height: 1200px) | |
{ | |
.zenzaScreenMode_big .zenzaPlayerContainer { | |
width: calc(1920px * 1.05); | |
height: 1080px; | |
} | |
} | |
/* 2560x1440 */ | |
@media | |
(min-width: 2976px) and (min-height: 1660px) | |
{ | |
.zenzaScreenMode_big .zenzaPlayerContainer { | |
width: calc(2560px * 1.05); | |
height: 1440px; | |
} | |
} | |
} | |
`.trim(); | |
NicoVideoPlayerDialogView.__tpl__ = (` | |
<div id="zenzaVideoPlayerDialog" class="zenzaVideoPlayerDialog zen-family zen-root"> | |
<div class="zenzaVideoPlayerDialogInner"> | |
<div class="menuContainer"></div> | |
<div class="zenzaPlayerContainer"> | |
<div class="popupMessageContainer"></div> | |
<div class="errorMessageContainer"></div> | |
<div class="loadingMessageContainer">動画読込中</div> | |
</div> | |
</div> | |
</div> | |
`).trim(); | |
class NicoVideoPlayerDialog extends Emitter { | |
constructor(params) { | |
super(); | |
this.initialize(params); | |
} | |
initialize(params) { | |
this._playerConfig = params.config; | |
this._state = params.state; | |
this._keyEmitter = params.keyHandler || ShortcutKeyEmitter.create( | |
params.config, | |
document.body, | |
global.emitter | |
); | |
this._initializeDom(); | |
this._keyEmitter.on('keyDown', this._onKeyDown.bind(this)); | |
this._keyEmitter.on('keyUp', this._onKeyUp.bind(this)); | |
this._id = 'ZenzaWatchDialog_' + Date.now() + '_' + Math.random(); | |
this._playerConfig.on('update', this._onPlayerConfigUpdate.bind(this)); | |
this._escBlockExpiredAt = -1; | |
this._videoFilter = new VideoFilter( | |
this._playerConfig.props.videoOwnerFilter, | |
this._playerConfig.props.videoTagFilter | |
); | |
this._savePlaybackPosition = | |
_.throttle(this._savePlaybackPosition.bind(this), 1000, {trailing: false}); | |
this._onToggleLike = _.debounce(this._onToggleLike.bind(this), 1000); | |
this.promise('firstVideoInitialized').then(() => console.nicoru('firstVideoInitialized')); | |
} | |
async _initializeDom() { | |
this._view = new NicoVideoPlayerDialogView({ | |
dialog: this, | |
playerConfig: this._playerConfig, | |
nicoVideoPlayer: this._nicoVideoPlayer, | |
playerState: this._state, | |
currentTimeGetter: () => this.currentTime | |
}); | |
await this._view.promise('dom-ready'); | |
this._initializeCommentPanel(); | |
this._$playerContainer = this._view.get$Container(); | |
this._view.on('command', this._onCommand.bind(this)); | |
this._view.on('postChat', (e, chat, cmd) => { | |
this.addChat(chat, cmd) | |
.then(() => e.resolve()) | |
.catch(() => e.reject()); | |
}); | |
MediaSessionApi.onCommand(this._onCommand.bind(this)); | |
} | |
async _initializeNicoVideoPlayer() { | |
if (this._nicoVideoPlayer) { | |
return this._nicoVideoPlayer; | |
} | |
await this._view.promise('dom-ready'); | |
const config = this._playerConfig; | |
const nicoVideoPlayer = this._nicoVideoPlayer = new NicoVideoPlayer({ | |
node: this._$playerContainer, | |
playerConfig: config, | |
playerState: this._state, | |
volume: Math.max(config.props.volume, 0), | |
loop: config.props.loop, | |
}); | |
this.threadLoader = ThreadLoader; | |
nicoVideoPlayer.on('loadedMetaData', this._onLoadedMetaData.bind(this)); | |
nicoVideoPlayer.on('ended', this._onVideoEnded.bind(this)); | |
nicoVideoPlayer.on('canPlay', this._onVideoCanPlay.bind(this)); | |
nicoVideoPlayer.on('play', this._onVideoPlay.bind(this)); | |
nicoVideoPlayer.on('pause', this._onVideoPause.bind(this)); | |
nicoVideoPlayer.on('playing', this._onVideoPlaying.bind(this)); | |
nicoVideoPlayer.on('seeking', this._onVideoSeeking.bind(this)); | |
nicoVideoPlayer.on('seeked', this._onVideoSeeked.bind(this)); | |
nicoVideoPlayer.on('stalled', this._onVideoStalled.bind(this)); | |
nicoVideoPlayer.on('waiting', this._onVideoStalled.bind(this)); | |
nicoVideoPlayer.on('timeupdate', this._onVideoTimeUpdate.bind(this)); | |
nicoVideoPlayer.on('progress', this._onVideoProgress.bind(this)); | |
nicoVideoPlayer.on('aspectRatioFix', this._onVideoAspectRatioFix.bind(this)); | |
nicoVideoPlayer.on('commentParsed', this._onCommentParsed.bind(this)); | |
nicoVideoPlayer.on('commentChange', this._onCommentChange.bind(this)); | |
nicoVideoPlayer.on('commentFilterChange', this._onCommentFilterChange.bind(this)); | |
nicoVideoPlayer.on('videoPlayerTypeChange', this._onVideoPlayerTypeChange.bind(this)); | |
nicoVideoPlayer.on('error', this._onVideoError.bind(this)); | |
nicoVideoPlayer.on('abort', this._onVideoAbort.bind(this)); | |
nicoVideoPlayer.on('volumeChange', this._onVolumeChange.bind(this)); | |
nicoVideoPlayer.on('volumeChange', _.debounce(this._onVolumeChangeEnd.bind(this), 1500)); | |
nicoVideoPlayer.on('command', this._onCommand.bind(this)); | |
this.emitResolve('nicovideo-player-ready'); | |
return nicoVideoPlayer; | |
} | |
execCommand(command, param) { | |
return this._onCommand(command, param); | |
} | |
_onCommand(command, param) { | |
let v; | |
switch (command) { | |
case 'volume': | |
this.volume = param; | |
break; | |
case 'volumeBy': | |
this.volume = this._nicoVideoPlayer.volume * param; | |
break; | |
case 'volumeUp': | |
this._nicoVideoPlayer.volumeUp(); | |
break; | |
case 'volumeDown': | |
this._nicoVideoPlayer.volumeDown(); | |
break; | |
case 'togglePlay': | |
this.togglePlay(); | |
break; | |
case 'pause': | |
this.pause(); | |
break; | |
case 'play': | |
this.play(); | |
break; | |
case 'fullscreen': | |
case 'toggle-fullscreen': | |
this._nicoVideoPlayer.toggleFullScreen(); | |
break; | |
case 'deflistAdd': | |
return this._onDeflistAdd(param); | |
case 'deflistRemove': | |
return this._onDeflistRemove(param); | |
case 'playlistAdd': | |
case 'playlistAppend': | |
this._onPlaylistAppend(param); | |
break; | |
case 'playlistInsert': | |
this._onPlaylistInsert(param); | |
break; | |
case 'playlistSetMylist': | |
this._onPlaylistSetMylist(param); | |
break; | |
case 'playlistSetUploadedVideo': | |
this._onPlaylistSetUploadedVideo(param); | |
break; | |
case 'playlistSetSearchVideo': | |
this._onPlaylistSetSearchVideo(param); | |
break; | |
case 'playlistSetSeries': | |
this._onPlaylistSetSeriesVideo(param); | |
break; | |
case 'playNextVideo': | |
this.playNextVideo(); | |
break; | |
case 'playPreviousVideo': | |
this.playPreviousVideo(); | |
break; | |
case 'shufflePlaylist': | |
this._playlist.shuffle(); | |
break; | |
case 'togglePlaylist': | |
this._playlist.toggleEnable(); | |
break; | |
case 'toggle-like': | |
return this._onToggleLike(); | |
case 'mylistAdd': | |
return this._onMylistAdd(param.mylistId, param.mylistName); | |
case 'mylistRemove': | |
return this._onMylistRemove(param.mylistId, param.mylistName); | |
case 'mylistWindow': | |
util.openMylistWindow(this._videoInfo.watchId); | |
break; | |
case 'seek': | |
case 'seekTo': | |
this.currentTime = param * 1; | |
break; | |
case 'seekBy': | |
this.currentTime = this.currentTime + param * 1; | |
break; | |
case 'seekPrevFrame': | |
case 'seekNextFrame': | |
this.execCommand('pause'); | |
this.execCommand('seekBy', command === 'seekNextFrame' ? 1/60 : -1/60); | |
break; | |
case 'seekRelativePercent': { | |
const dur = this._videoInfo.duration; | |
const mv = Math.abs(param.movePerX) > 10 ? | |
(param.movePerX / 2) : (param.movePerX / 8); | |
const pos = this.currentTime + (mv * dur / 100); | |
this.currentTime=Math.min(Math.max(0, pos), dur); | |
break; | |
} | |
case 'seekToResumePoint': | |
this.currentTime=this._videoInfo.initialPlaybackTime; | |
break; | |
case 'addWordFilter': | |
this._nicoVideoPlayer.filter.addWordFilter(param); | |
break; | |
case 'setWordRegFilter': | |
case 'setWordRegFilterFlags': | |
this._nicoVideoPlayer.filter.setWordRegFilter(param); | |
break; | |
case 'addUserIdFilter': | |
this._nicoVideoPlayer.filter.addUserIdFilter(param); | |
break; | |
case 'addCommandFilter': | |
this._nicoVideoPlayer.filter.addCommandFilter(param); | |
break; | |
case 'setWordFilterList': | |
this._nicoVideoPlayer.filter.wordFilterList = param; | |
break; | |
case 'setUserIdFilterList': | |
this._nicoVideoPlayer.filter.userIdFilterList = param; | |
break; | |
case 'setCommandFilterList': | |
this._nicoVideoPlayer.filter.commandFilterList = param; | |
break; | |
case 'openNow': | |
this.open(param, {openNow: true}); | |
break; | |
case 'open': | |
this.open(param); | |
break; | |
case 'close': | |
this.close(param); | |
break; | |
case 'reload': | |
this.reload({currentTime: this.currentTime}); | |
break; | |
case 'openGinza': | |
window.open('//www.nicovideo.jp/watch/' + this._watchId, 'watchGinza'); | |
break; | |
case 'reloadComment': | |
this.reloadComment(param); | |
break; | |
case 'playbackRate': | |
this._playerConfig.setValue(command, param); | |
MediaSessionApi.updatePositionStateByMedia(this); | |
break; | |
case 'shiftUp': { | |
v = parseFloat(this._playerConfig.getValue('playbackRate'), 10); | |
if (v < 2) { | |
v += 0.25; | |
} else { | |
v = Math.min(10, v + 0.5); | |
} | |
this._playerConfig.setValue('playbackRate', v); | |
} | |
break; | |
case 'shiftDown': { | |
v = parseFloat(this._playerConfig.getValue('playbackRate'), 10); | |
if (v > 2) { | |
v -= 0.5; | |
} else { | |
v = Math.max(0.1, v - 0.25); | |
} | |
this._playerConfig.setValue('playbackRate', v); | |
} | |
break; | |
case 'screenShot': | |
if (this._state.isYouTube) { | |
util.capTube({ | |
title: this._videoInfo.title, | |
videoId: this._videoInfo.videoId, | |
author: this._videoInfo.owner.name | |
}); | |
return; | |
} | |
this._nicoVideoPlayer.getScreenShot(); | |
break; | |
case 'screenShotWithComment': | |
if (this._state.isYouTube) { | |
return; | |
} | |
this._nicoVideoPlayer.getScreenShotWithComment(); | |
break; | |
case 'nextVideo': | |
this._nextVideo = param; | |
break; | |
case 'nicosSeek': | |
this._onNicosSeek(param); | |
break; | |
case 'fastSeek': | |
this._nicoVideoPlayer.fastSeek(param); | |
break; | |
case 'setVideo': | |
this.setVideo(param); | |
break; | |
case 'selectTab': | |
this._state.currentTab = param; | |
break; | |
case 'nicoru': | |
this.threadLoader.nicoru(this._videoInfo.msgInfo, param).catch(e => { | |
this.execCommand('alert', e.message || 'ニコれなかった><'); | |
}); | |
break; | |
case 'update-smileVideoQuality': | |
this._playerConfig.props.videoServerType = 'smile'; | |
this._playerConfig.props.smileVideoQuality = param; | |
this.reload({videoServerType: 'smile', economy: param === 'eco'}); | |
break; | |
case 'update-dmcVideoQuality': | |
this._playerConfig.props.videoServerType = 'dmc'; | |
this._playerConfig.props.dmcVideoQuality = param; | |
this.reload({videoServerType: 'dmc'}); | |
break; | |
case 'update-videoServerType': | |
this._playerConfig.props.videoServerType = param; | |
this.reload({videoServerType: param === 'dmc' ? 'dmc' : 'smile'}); | |
break; | |
case 'update-commentLanguage': | |
command = command.replace(/^update-/, ''); | |
if (this._playerConfig.props[command] === param) { | |
break; | |
} | |
this._playerConfig.props[command] = param; | |
this.reloadComment(param); | |
break; | |
case 'saveMymemory': | |
util.saveMymemory(this, this._state.videoInfo); | |
break; | |
default: | |
this.emit('command', command, param); | |
} | |
} | |
_onKeyDown(name, e, param) { | |
this._onKeyEvent(name, e, param); | |
} | |
_onKeyUp(name, e, param) { | |
this._onKeyEvent(name, e, param); | |
} | |
_onKeyEvent(name, e, param) { | |
if (!this._state.isOpen) { | |
const lastWatchId = this._playerConfig.props.lastWatchId; | |
if (name === 'RE_OPEN' && lastWatchId) { | |
this.open(lastWatchId); | |
e.preventDefault(); | |
} | |
return; | |
} | |
const TABLE = { | |
'RE_OPEN': 'reload', | |
'PAUSE': 'pause', | |
'TOGGLE_PLAY': 'togglePlay', | |
'SPACE': 'togglePlay', | |
'FULL': 'toggle-fullscreen', | |
'TOGGLE_PLAYLIST': 'togglePlaylist', | |
'DEFLIST': 'deflistAdd', | |
'DEFLIST_REMOVE': 'deflistRemove', | |
'VIEW_COMMENT': 'toggle-showComment', | |
'TOGGLE_LOOP': 'toggle-loop', | |
'MUTE': 'toggle-mute', | |
'VOL_UP': 'volumeUp', | |
'VOL_DOWN': 'volumeDown', | |
'SEEK_TO': 'seekTo', | |
'SEEK_BY': 'seekBy', | |
'SEEK_PREV_FRAME': 'seekPrevFrame', | |
'SEEK_NEXT_FRAME': 'seekNextFrame', | |
'NEXT_VIDEO': 'playNextVideo', | |
'PREV_VIDEO': 'playPreviousVideo', | |
'PLAYBACK_RATE': 'playbackRate', | |
'SHIFT_UP': 'shiftUp', | |
'SHIFT_DOWN': 'shiftDown', | |
'SCREEN_MODE': 'screenMode', | |
'SCREEN_SHOT': 'screenShot', | |
'SCREEN_SHOT_WITH_COMMENT': 'screenShotWithComment' | |
}; | |
switch (name) { | |
case 'ESC': | |
if (Date.now() < this._escBlockExpiredAt) { | |
window.console.log('block ESC'); | |
break; | |
} | |
this._escBlockExpiredAt = Date.now() + 1000 * 2; | |
if (!Fullscreen.now()) { | |
this.close(); | |
} | |
break; | |
case 'INPUT_COMMENT': | |
this._view.focusToCommentInput(); | |
break; | |
default: | |
if (!TABLE[name]) { return; } | |
this.execCommand(TABLE[name], param); | |
} | |
const screenMode = this._playerConfig.props.screenMode; | |
if (['small', 'sideView'].includes(screenMode) && ['TOGGLE_PLAY'].includes(name)) { | |
return; | |
} | |
e.preventDefault(); | |
e.stopPropagation(); | |
} | |
_onPlayerConfigUpdate(key, value) { | |
if (!this._nicoVideoPlayer) { return; } | |
const np = this._nicoVideoPlayer, filter = np.filter; | |
switch (key) { | |
case 'enableFilter': | |
filter.isEnable = value; | |
break; | |
case 'wordFilter': | |
filter.wordFilterList = value; | |
break; | |
case 'userIdFilter': | |
filter.userIdFilterList = value; | |
break; | |
case 'commandFilter': | |
filter.commandFilterList = value; | |
break; | |
case 'filter.fork0': | |
case 'filter.fork1': | |
case 'filter.fork2': | |
case 'removeNgMatchedUser': | |
filter[key.replace(/^.*\./, '')] = value; | |
break; | |
} | |
} | |
_updateScreenMode(mode) { | |
this.emit('screenModeChange', mode); | |
} | |
_onPlaylistAppend(watchId) { | |
this._playlist.append(watchId); | |
} | |
_onPlaylistInsert(watchId) { | |
this._playlist.insert(watchId); | |
} | |
_onPlaylistSetMylist(mylistId, option) { | |
option = Object.assign({watchId: this._watchId}, option || {}); | |
option.sort = isNaN(option.sort) ? 7 : option.sort; | |
option.insert = this._playlist.isEnable; | |
let query = this._videoWatchOptions.query; | |
option.shuffle = parseInt(query.shuffle, 10) === 1; | |
this._playlist.loadFromMylist(mylistId, option).then(result => { | |
this.execCommand('notify', result.message); | |
this._state.currentTab = 'playlist'; | |
this._playlist.insertCurrentVideo(this._videoInfo); | |
}, | |
() => this.execCommand('alert', 'マイリストのロード失敗')); | |
} | |
_onPlaylistSetUploadedVideo(userId, option) { | |
option = Object.assign({watchId: this._watchId}, option || {}); | |
option.insert = this._playlist.isEnable; | |
this._playlist.loadUploadedVideo(userId, option).then(result => { | |
this.execCommand('notify', result.message); | |
this._state.currentTab = 'playlist'; | |
this._playlist.insertCurrentVideo(this._videoInfo); | |
}, | |
err => this.execCommand('alert', err.message || '投稿動画一覧のロード失敗')); | |
} | |
_onPlaylistSetSearchVideo(params) { | |
let option = Object.assign({watchId: this._watchId}, params.option || {}); | |
let word = params.word; | |
option.insert = this._playlist.isEnable; | |
if (option.owner) { | |
let ownerId = parseInt(this._videoInfo.owner.id, 10); | |
if (this._videoInfo.isChannel) { | |
option.channelId = ownerId; | |
} else { | |
option.userId = ownerId; | |
} | |
} | |
delete option.owner; | |
let query = this._videoWatchOptions.query; | |
option = Object.assign(option, query); | |
this._state.currentTab = 'playlist'; | |
this._playlist.loadSearchVideo(word, option).then(result => { | |
this.execCommand('notify', result.message); | |
this._playlist.insertCurrentVideo(this._videoInfo); | |
global.emitter.emitAsync('searchVideo', {word, option}); | |
window.setTimeout(() => this._playlist.scrollToActiveItem(), 1000); | |
}, | |
err => { | |
this.execCommand('alert', err.message || '検索失敗または該当無し: 「' + word + '」'); | |
}); | |
} | |
_onPlaylistSetSeriesVideo(id, option = {}) { | |
option = Object.assign({watchId: this._watchId}, option || {}); | |
option.insert = this._playlist.isEnable; | |
this._state.currentTab = 'playlist'; | |
this._playlist.loadSeriesList(id, option).then(result => { | |
this.execCommand('notify', result.message); | |
this._playlist.insertCurrentVideo(this._videoInfo); | |
window.setTimeout(() => this._playlist.scrollToActiveItem(), 1000); | |
}, | |
err => this.execCommand('alert', err.message || `シリーズリストの取得に失敗: series/${id}`)); | |
} | |
_onPlaylistStatusUpdate() { | |
let playlist = this._playlist; | |
this._playerConfig.setValue('playlistLoop', playlist.isLoop); | |
this._state.isPlaylistEnable = playlist.isEnable; | |
if (playlist.isEnable) { | |
this._playerConfig.setValue('loop', false); | |
} | |
this._view.blinkTab('playlist'); | |
} | |
_onCommentPanelStatusUpdate() { | |
let commentPanel = this._commentPanel; | |
this._playerConfig.setValue( | |
'enableCommentPanelAutoScroll', commentPanel.isAutoScroll); | |
} | |
_onDeflistAdd(watchId) { | |
if (this._state.isUpdatingDeflist || !util.isLogin()) { | |
return; | |
} | |
const unlock = () => this._state.isUpdatingDeflist = false; | |
this._state.isUpdatingDeflist = true; | |
let timer = window.setTimeout(unlock, 10000); | |
watchId = watchId || this._videoInfo.watchId; | |
let description; | |
if (!this._mylistApiLoader) { | |
this._mylistApiLoader = MylistApiLoader; | |
} | |
const {enableAutoMylistComment} = this._playerConfig.props; | |
(() => { | |
if (watchId === this._watchId || !enableAutoMylistComment) { | |
return Promise.resolve(this._videoInfo); | |
} | |
return ThumbInfoLoader.load(watchId); | |
})().then(info => { | |
const originalVideoId = info.originalVideoId ? | |
`元動画: ${info.originalVideoId}` : ''; | |
description = enableAutoMylistComment ? | |
`投稿者: ${info.owner.name} ${info.owner.linkId} ${originalVideoId}` : ''; | |
}).then(() => this._mylistApiLoader.addDeflistItem(watchId, description)) | |
.then(result => this.execCommand('notify', result.message)) | |
.catch(err => this.execCommand('alert', err.message ? err.message : 'とりあえずマイリストに登録失敗')) | |
.then(() => { | |
window.clearTimeout(timer); | |
timer = window.setTimeout(unlock, 2000); | |
}); | |
} | |
_onDeflistRemove(watchId) { | |
if (this._state.isUpdatingDeflist || !util.isLogin()) { | |
return; | |
} | |
const unlock = () => this._state.isUpdatingDeflist = false; | |
this._state.isUpdatingDeflist = true; | |
let timer = window.setTimeout(unlock, 10000); | |
watchId = watchId || this._videoInfo.watchId; | |
if (!this._mylistApiLoader) { | |
this._mylistApiLoader = MylistApiLoader; | |
} | |
this._mylistApiLoader.removeDeflistItem(watchId) | |
.then(result => this.execCommand('notify', result.message)) | |
.catch(err => this.execCommand('alert', err.message)) | |
.then(() => { | |
window.clearTimeout(timer); | |
timer = window.setTimeout(unlock, 2000); | |
}); | |
} | |
_onToggleLike() { | |
if (!util.isLogin()) { return; } | |
const videoId = this._videoInfo.videoId; | |
const isLiked = this._videoInfo.isLiked; | |
(isLiked ? LikeApi.unlike(videoId) : LikeApi.like(videoId)) | |
.then(result => { | |
const message = result.data ? (result.data.thanksMessage || '') : ''; | |
if (message) { | |
this.execCommand('notify', `${message}`); | |
} else { | |
this.execCommand('notify', | |
isLiked ? '(・A・)ノシ' : '(・∀・)ィィネ!!'); | |
} | |
this._state.isLiked = this._videoInfo.isLiked = !isLiked; | |
}).catch(err => { | |
console.warn(err); | |
this.execCommand('alert', 'いいね!できなかった'); | |
}); | |
} | |
_onMylistAdd(groupId, mylistName) { | |
if (this._state.isUpdatingMylist || !util.isLogin()) { | |
return; | |
} | |
const unlock = () => this._state.isUpdatingMylist = false; | |
this._state.isUpdatingMylist = true; | |
let timer = window.setTimeout(unlock, 10000); | |
const owner = this._videoInfo.owner; | |
const originalVideoId = this._videoInfo.originalVideoId ? | |
`元動画: ${this._videoInfo.originalVideoId}` : ''; | |
const watchId = this._videoInfo.watchId; | |
const description = | |
this._playerConfig.getValue('enableAutoMylistComment') ? | |
`投稿者: ${owner.name} ${owner.linkId} ${originalVideoId}` : ''; | |
if (!this._mylistApiLoader) { | |
this._mylistApiLoader = MylistApiLoader; | |
} | |
this._mylistApiLoader.addMylistItem(watchId, groupId, description) | |
.then(result => this.execCommand('notify', `${result.message}: ${mylistName}`)) | |
.catch(err => this.execCommand('alert', `${err.message}: ${mylistName}`)) | |
.then(() => { | |
window.clearTimeout(timer); | |
timer = window.setTimeout(unlock, 2000); | |
}); | |
} | |
_onMylistRemove(groupId, mylistName) { | |
if (this._state.isUpdatingMylist || !util.isLogin()) { | |
return; | |
} | |
const unlock = () => this._state.isUpdatingMylist = false; | |
this._state.isUpdatingMylist = true; | |
let timer = window.setTimeout(unlock, 10000); | |
let watchId = this._videoInfo.watchId; | |
if (!this._mylistApiLoader) { | |
this._mylistApiLoader = MylistApiLoader; | |
} | |
this._mylistApiLoader.removeMylistItem(watchId, groupId) | |
.then(result => this.execCommand('notify', `${result.message}: ${mylistName}`)) | |
.catch(err => this.execCommand('alert', `${err.message}: ${mylistName}`)) | |
.then(() => { | |
window.clearTimeout(timer); | |
timer = window.setTimeout(unlock, 2000); | |
}); | |
} | |
_onCommentParsed() { | |
const lang = this._playerConfig.getValue('commentLanguage'); | |
this.emit('commentParsed', lang, this._threadInfo); | |
global.emitter.emit('commentParsed'); | |
} | |
_onCommentChange() { | |
const lang = this._playerConfig.getValue('commentLanguage'); | |
this.emit('commentChange', lang, this._threadInfo); | |
global.emitter.emit('commentChange'); | |
} | |
_onCommentFilterChange(filter) { | |
const config = this._playerConfig; | |
config.props.enableFilter = filter.isEnable; | |
config.props.wordFilter = filter.wordFilterList; | |
config.props.userIdFilter = filter.userIdFilterList; | |
config.props.commandFilter = filter.commandFilterList; | |
this.emit('commentFilterChange', filter); | |
} | |
_onVideoPlayerTypeChange(type = '') { | |
switch (type.toLowerCase()) { | |
case 'youtube': | |
this._state.setState({isYouTube: true}); | |
break; | |
default: | |
this._state.setState({isYouTube: false}); | |
} | |
} | |
_onNicosSeek(time) { | |
const ct = this.currentTime; | |
window.console.info('nicosSeek!', time); | |
if (this.isPlaylistEnable) { | |
if (ct < time) { | |
this.execCommand('fastSeek', time); | |
} | |
} else { | |
this.execCommand('fastSeek', time); | |
} | |
} | |
show() { | |
this._state.isOpen = true; | |
} | |
hide() { | |
this._state.isOpen = false; | |
} | |
async open(watchId, options) { | |
if (!watchId) { | |
return; | |
} | |
if (Date.now() - this._lastOpenAt < 1500 && this._watchId === watchId) { | |
return; | |
} | |
this.refreshLastPlayerId(); | |
this._requestId = 'play-' + Math.random(); | |
this._videoWatchOptions = options = new VideoWatchOptions(watchId, options, this._playerConfig); | |
if (!options.isPlaylistStartRequest && | |
this.isPlaying && this.isPlaylistEnable && !options.isOpenNow) { | |
this._onPlaylistInsert(watchId); | |
return; | |
} | |
window.console.log('%copen video: ', 'color: blue;', watchId); | |
window.console.time('動画選択から再生可能までの時間 watchId=' + watchId); | |
let nicoVideoPlayer = this._nicoVideoPlayer; | |
if (!nicoVideoPlayer) { | |
nicoVideoPlayer = await this._initializeNicoVideoPlayer(); | |
} else { | |
if (this._videoInfo) { | |
this._savePlaybackPosition(this._videoInfo.contextWatchId, this.currentTime); | |
} | |
nicoVideoPlayer.close(); | |
this._view.clearPanel(); | |
this.emit('beforeVideoOpen'); | |
if (this._videoSession) { | |
this._videoSession.close(); | |
} | |
} | |
this._state.resetVideoLoadingStatus(); | |
this._state.isCommentReady = false; | |
this._watchId = watchId; | |
this._lastCurrentTime = 0; | |
this._lastOpenAt = Date.now(); | |
this._state.isError = false; | |
Promise.all([ | |
VideoInfoLoader.load(watchId, options.videoLoadOptions), | |
WatchInfoCacheDb.get(this._watchId), | |
this._initializePlaylist() //videoinfo取得に300msくらいかかってるぽいから他のことやろうか | |
]).then(this._onVideoInfoLoaderLoad.bind(this, this._requestId) | |
).catch(this._onVideoInfoLoaderFail.bind(this, this._requestId)); | |
this.show(); | |
if (this._playerConfig.getValue('autoFullScreen') && !util.fullscreen.now()) { | |
nicoVideoPlayer.requestFullScreen(); | |
} | |
this.emit('open', watchId, options); | |
global.emitter.emitAsync('DialogPlayerOpen', watchId, options); | |
global.emitter.emitResolve('firstPlayerOpen'); | |
} | |
get isOpen() { | |
return this._state.isOpen; | |
} | |
reload(options) { | |
options = this._videoWatchOptions.createForReload(options); | |
if (this._lastCurrentTime > 0) { | |
options.currentTime = this._lastCurrentTime; | |
} | |
this.open(this._watchId, options); | |
} | |
get currentTime() { | |
if (!this._nicoVideoPlayer) { | |
return 0; | |
} | |
const ct = this._nicoVideoPlayer.currentTime * 1; | |
if (!this._state.isError && ct > 0) { | |
this._lastCurrentTime = ct; | |
} | |
return this._lastCurrentTime; | |
} | |
set currentTime(sec) { | |
if (!this._nicoVideoPlayer) { | |
return; | |
} | |
sec = Math.max(0, sec); | |
this._nicoVideoPlayer.currentTime=sec; | |
this._lastCurrentTime = sec; | |
MediaSessionApi.updatePositionStateByMedia(this); | |
} | |
get id() { return this._id;} | |
get isLastOpenedPlayer() { | |
return this.id === this._playerConfig.props.lastPlayerId; | |
} | |
refreshLastPlayerId() { | |
if (this.isLastOpenedPlayer) { | |
return; | |
} | |
this._playerConfig.props.lastPlayerId = ''; | |
this._playerConfig.props.lastPlayerId = this.id; | |
} | |
async _onVideoInfoLoaderLoad(requestId, [videoInfoData, localCacheData]) { | |
console.log('VideoInfoLoader.load!', requestId, this._watchId, videoInfoData); | |
if (this._requestId !== requestId) { | |
return; | |
} | |
const videoInfo = this._videoInfo = new VideoInfoModel(videoInfoData, localCacheData); | |
this._watchId = videoInfo.watchId; | |
WatchInfoCacheDb.put(this._watchId, {videoInfo}); | |
let serverType = 'dmc'; | |
if (!videoInfo.isDmcAvailable) { | |
serverType = 'smile'; | |
} else if (videoInfo.isDmcOnly) { | |
serverType = 'dmc'; | |
} else if (['dmc', 'smile'].includes(this._videoWatchOptions.videoServerType)) { | |
serverType = this._videoWatchOptions.videoServerType; | |
} else if (this._playerConfig.props.videoServerType === 'smile') { | |
serverType = 'smile'; | |
} else { | |
const disableDmc = | |
this._playerConfig.props.autoDisableDmc && | |
this._videoWatchOptions.videoServerType !== 'smile' && | |
videoInfo.maybeBetterQualityServerType === 'smile'; | |
serverType = disableDmc ? 'smile' : 'dmc'; | |
} | |
this._state.setState({ | |
isDmcAvailable: videoInfo.isDmcAvailable, | |
isCommunity: videoInfo.isCommunityVideo, | |
isMymemory: videoInfo.isMymemory, | |
isChannel: videoInfo.isChannel, | |
isLiked: videoInfo.isLiked | |
}); | |
MediaSessionApi.updateByVideoInfo(this._videoInfo); | |
const isHLSRequired = videoInfo.dmcInfo && videoInfo.dmcInfo.isHLSRequired; | |
const isHLSSupported = !!global.debug.isHLSSupported || | |
document.createElement('video').canPlayType('application/x-mpegURL') !== ''; | |
const useHLS = isHLSSupported && (isHLSRequired || !this._playerConfig.props['video.hls.enableOnlyRequired']); | |
this._videoSession = await VideoSessionWorker.create({ | |
videoInfo, | |
videoQuality: this._playerConfig.props.dmcVideoQuality, | |
serverType, | |
isPlayingCallback: () => this.isPlaying, | |
useWellKnownPort: true, | |
useHLS | |
}); | |
if (this._videoFilter.isNgVideo(videoInfo)) { | |
return this._onVideoFilterMatch(); | |
} | |
if (this._videoSession.isDmc) { | |
NVWatchCaller.call(videoInfo.dmcInfo.trackingId) | |
.then(() => this._videoSession.connect()) | |
.then(sessionInfo => { | |
this.setVideo(sessionInfo.url); | |
videoInfo.setCurrentVideo(sessionInfo.url); | |
this.emit('videoServerType', 'dmc', sessionInfo, videoInfo); | |
}) | |
.catch(this._onVideoSessionFail.bind(this)); | |
} else { | |
if (this._playerConfig.props.enableVideoSession) { | |
this._videoSession.connect(); | |
} | |
videoInfo.setCurrentVideo(videoInfo.videoUrl); | |
this.setVideo(videoInfo.videoUrl); | |
this.emit('videoServerType', 'smile', {}, videoInfo); | |
} | |
this._state.videoInfo = videoInfo; | |
this._state.isDmcPlaying = this._videoSession.isDmc; | |
this.loadComment(videoInfo.msgInfo); | |
this.emit('loadVideoInfo', videoInfo); | |
this.emitResolve('firstVideoInitialized', this._watchId); | |
if (Fullscreen.now() || this._playerConfig.props.screenMode === 'wide') { | |
this.execCommand('notifyHtml', | |
`<img src="${textUtil.escapeHtml(videoInfo.thumbnail)}" style="width: 96px;">` + | |
util.escapeToZenkaku(videoInfo.title) | |
); | |
} | |
} | |
setVideo(url) { | |
this._state.setState({ | |
isYouTube: url.indexOf('youtube') >= 0, | |
currentSrc: url | |
}); | |
} | |
loadComment(msgInfo) { | |
msgInfo.language = this._playerConfig.props.commentLanguage; | |
this.threadLoader.load(msgInfo).then( | |
this._onCommentLoadSuccess.bind(this, this._requestId), | |
this._onCommentLoadFail.bind(this, this._requestId) | |
); | |
} | |
reloadComment(param = {}) { | |
const msgInfo = Object.assign({}, this._videoInfo.msgInfo); | |
if (typeof param.when === 'number') { | |
msgInfo.when = param.when; | |
} | |
this.loadComment(msgInfo); | |
} | |
_onVideoInfoLoaderFail(requestId, e) { | |
const watchId = e.watchId; | |
window.console.error('_onVideoInfoLoaderFail', watchId, e); | |
if (this._requestId !== requestId) { | |
return; | |
} | |
this._setErrorMessage(e.message || '通信エラー', watchId); | |
this._state.isError = true; | |
if (e.info) { | |
this._videoInfo = new VideoInfoModel(e.info); | |
this._state.videoInfo = this._videoInfo; | |
this.emit('loadVideoInfoFail', this._videoInfo); | |
} else { | |
this.emit('loadVideoInfoFail'); | |
} | |
global.emitter.emitAsync('loadVideoInfoFail', e); | |
if (!this.isPlaylistEnable) { | |
return; | |
} | |
if (e.reason === 'forbidden' || e.info.isPlayable === false) { | |
window.setTimeout(() => this.playNextVideo(), 3000); | |
} | |
} | |
_onVideoSessionFail(result) { | |
window.console.error('dmc fail', result); | |
this._setErrorMessage( | |
`動画の読み込みに失敗しました(dmc.nico) ${result && result.message || ''}`, this._watchId); | |
this._state.setState({isError: true, isLoading: false}); | |
if (this.isPlaylistEnable) { | |
window.setTimeout(() => this.playNextVideo(), 3000); | |
} | |
} | |
_onVideoPlayStartFail(err) { | |
window.console.error('動画再生開始に失敗', err); | |
if (!(err instanceof DOMException)) { // | |
return; | |
} | |
console.warn('play() request was rejected code: %s. message: %s', err.code, err.message); | |
const message = err.message; | |
switch (message) { | |
case 'SessionClosedError': | |
if (this._playserState.isError) { break; } | |
this._setErrorMessage('動画の再生開始に失敗しました', this._watchId); | |
this._state.setVideoErrorOccurred(); | |
break; | |
case 'AbortError': // 再生開始を待っている間に動画変更などで中断された等 | |
case 'NotAllowedError': // 自動再生のブロック | |
default: | |
break; | |
} | |
this.emit('loadVideoPlayStartFail'); | |
global.emitter.emitAsync('loadVideoPlayStartFail'); | |
} | |
_onVideoFilterMatch() { | |
window.console.error('ng video', this._watchId); | |
this._setErrorMessage('再生除外対象の動画または投稿者です'); | |
this._state.isError = true; | |
this.emit('error'); | |
if (this.isPlaylistEnable) { | |
window.setTimeout(() => this.playNextVideo(), 3000); | |
} | |
} | |
_setErrorMessage(msg) { | |
this._state.errorMessage = msg; | |
} | |
_onCommentLoadSuccess(requestId, result) { | |
if (requestId !== this._requestId) { | |
return; | |
} | |
let options = { | |
replacement: this._videoInfo.replacementWords, | |
duration: this._videoInfo.duration, | |
mainThreadId: result.threadInfo.threadId, | |
format: result.format | |
}; | |
this._nicoVideoPlayer.closeCommentPlayer(); | |
this._threadInfo = result.threadInfo; | |
this._nicoVideoPlayer.setComment(result.body, options); | |
WatchInfoCacheDb.put(this._watchId, {threadInfo: result.threadInfo}); | |
this._state.isCommentReady = true; | |
this._state.isWaybackMode = result.threadInfo.isWaybackMode; | |
this.emit('commentReady', result, this._threadInfo); | |
if (result.threadInfo.totalResCount !== this._videoInfo.count.comment) { | |
this._state.count = { | |
...this._state.count, comment: result.threadInfo.totalResCount | |
}; | |
this.emit('videoCount', {comment: result.threadInfo.totalResCount}); | |
} | |
} | |
_onCommentLoadFail(requestId, e) { | |
if (requestId !== this._requestId) { | |
return; | |
} | |
this.execCommand('alert', e.message); | |
} | |
_onLoadedMetaData() { | |
if (this._state.isYouTube) { | |
return; | |
} | |
let currentTime = this._videoWatchOptions.currentTime; | |
if (currentTime > 0) { | |
this.currentTime=currentTime; | |
} | |
} | |
async _onVideoCanPlay() { | |
if (!this._state.isLoading) { | |
return; | |
} | |
window.console.timeEnd('動画選択から再生可能までの時間 watchId=' + this._watchId); | |
this._playerConfig.props.lastWatchId = this._watchId; | |
WatchInfoCacheDb.put(this._watchId, {watchCount: 1}); | |
await this.promise('playlist-ready'); | |
if (this._videoWatchOptions.isPlaylistStartRequest) { | |
let option = this._videoWatchOptions.mylistLoadOptions; | |
let query = this._videoWatchOptions.query; | |
option.append = this.isPlaying && this._playlist.isEnable; | |
option.shuffle = parseInt(query.shuffle, 10) === 1; | |
console.log('playlist option:', option); | |
if (query.playlist_type === 'mylist') { | |
this._playlist.loadFromMylist(option.group_id, option); | |
} else if (query.playlist_type === 'deflist') { | |
this._playlist.loadFromMylist('deflist', option); | |
} else if (query.playlist_type === 'tag' || query.playlist_type === 'search') { | |
let word = query.tag || query.keyword; | |
option.searchType = query.tag ? 'tag' : ''; | |
option = Object.assign(option, query); | |
this._playlist.loadSearchVideo(word, option, this._playerConfig.props['search.limit']); | |
} | |
this._playlist.toggleEnable(true); | |
} | |
this._playlist.insertCurrentVideo(this._videoInfo); | |
if (this._videoInfo.watchId !== this._videoInfo.videoId && | |
this._videoInfo.videoId.startsWith('so')) { | |
this._playlist.removeItemByWatchId(this._videoInfo.watchId); | |
} | |
this._state.setVideoCanPlay(); | |
this.emitAsync('canPlay', this._watchId, this._videoInfo, this._videoWatchOptions); | |
this.emitResolve('firstVideoCanPlay', this._watchId, this._videoInfo, this._videoWatchOptions); | |
if (this._videoWatchOptions.eventType === 'playlist' && this.isOpen) { | |
this.play(); | |
} | |
if (this._nextVideo) { | |
const nextVideo = this._nextVideo; | |
this._nextVideo = null; | |
if (this._playerConfig.props.enableNicosJumpVideo) { | |
const nv = this._playlist.findByWatchId(nextVideo); | |
if (nv && nv.isPlayed()) { | |
return; | |
} // 既にリストにあって再生済みなら追加しない(無限ループ対策) | |
this.execCommand('notify', '@ジャンプ: ' + nextVideo); | |
this.execCommand('playlistInsert', nextVideo); | |
} | |
} | |
} | |
_onVideoPlay() { | |
this._state.setPlaying(); | |
MediaSessionApi.updatePositionStateByMedia(this); | |
this.emit('play'); | |
} | |
_onVideoPlaying() { | |
this._state.setPlaying(); | |
this.emit('playing'); | |
} | |
_onVideoSeeking() { | |
this._state.isSeeking = true; | |
this.emit('seeking'); | |
} | |
_onVideoSeeked() { | |
this._state.isSeeking = false; | |
MediaSessionApi.updatePositionStateByMedia(this); | |
this.emit('seeked'); | |
} | |
_onVideoPause() { | |
this._state.setPausing(); | |
this._savePlaybackPosition(this._videoInfo.contextWatchId, this.currentTime); | |
this.emit('pause'); | |
} | |
_onVideoStalled() { | |
this._state.isStalled = true; | |
this.emit('stalled'); | |
} | |
_onVideoTimeUpdate() { | |
this._state.isStalled = false; | |
} | |
_onVideoProgress(range, currentTime) { | |
this.emit('progress', range, currentTime); | |
} | |
async _onVideoError(e) { | |
this._state.setVideoErrorOccurred(); | |
if (e.type === 'youtube') { | |
return this._onYouTubeVideoError(e); | |
} | |
if (!this._videoInfo) { | |
this._setErrorMessage('動画の再生に失敗しました。'); | |
return; | |
} | |
const retry = params => { | |
setTimeout(() => { | |
if (!this.isOpen) { | |
return; | |
} | |
this.reload(params); | |
}, 3000); | |
}; | |
const sessionState = await this._videoSession.getState(); | |
const {isDmc, isDeleted, isAbnormallyClosed} = sessionState; | |
const videoWatchOptions = this._videoWatchOptions; | |
const code = (e && e.target && e.target.error && e.target.error.code) || 0; | |
window.console.error('VideoError!', code, e, (e.target && e.target.error), {isDeleted, isAbnormallyClosed}); | |
if (Date.now() - this._lastOpenAt > 3 * 60 * 1000 && isDeleted && !isAbnormallyClosed) { | |
if (videoWatchOptions.reloadCount < 5) { | |
retry(); | |
} else { | |
this._setErrorMessage('動画のセッションが切断されました。'); | |
} | |
} else if (!isDmc && this._videoInfo.isDmcAvailable) { | |
this._setErrorMessage('SMILE動画の再生に失敗しました。DMC動画に接続します。'); | |
retry({economy: false, videoServerType: 'dmc'}); | |
} else if (!isDmc && (!this._videoWatchOptions.isEconomySelected && !this._videoInfo.isEconomy)) { | |
this._setErrorMessage('動画の再生に失敗しました。エコノミー動画に接続します。'); | |
retry({economy: true, videoServerType: 'smile'}); | |
} else { | |
this._setErrorMessage('動画の再生に失敗しました。'); | |
} | |
this.emit('error', e, code); | |
} | |
_onYouTubeVideoError(e) { | |
window.console.error('onYouTubeVideoError!', e); | |
this._setErrorMessage(e.description); | |
this.emit('error', e); | |
if (e.fallback) { | |
setTimeout(() => this.reload({isAutoZenTubeDisabled: true}), 3000); | |
} | |
} | |
_onVideoAbort() { | |
this.emit('abort'); | |
} | |
_onVideoAspectRatioFix(ratio) { | |
this.emit('aspectRatioFix', ratio); | |
} | |
_onVideoEnded() { | |
this.emitAsync('ended'); | |
this._state.setVideoEnded(); | |
this._savePlaybackPosition(this._videoInfo.contextWatchId, 0); | |
if (this.isPlaylistEnable && this._playlist.hasNext) { | |
this.playNextVideo({eventType: 'playlist'}); | |
return; | |
} else if (this._playlist) { | |
this._playlist.toggleEnable(false); | |
} | |
const isAutoCloseFullScreen = | |
this._videoWatchOptions.hasKey('autoCloseFullScreen') ? | |
this._videoWatchOptions.isAutoCloseFullScreen : | |
this._playerConfig.getValue('autoCloseFullScreen'); | |
if (Fullscreen.now() && isAutoCloseFullScreen) { | |
Fullscreen.cancel(); | |
} | |
global.emitter.emitAsync('videoEnded'); | |
} | |
_onVolumeChange(vol, mute) { | |
this.emit('volumeChange', vol, mute); | |
} | |
_onVolumeChangeEnd(vol, mute) { | |
this.emit('volumeChangeEnd', vol, mute); | |
} | |
_savePlaybackPosition(contextWatchId, ct) { | |
if (!util.isLogin()) { | |
return; | |
} | |
const vi = this._videoInfo; | |
if (!vi) { | |
return; | |
} | |
const dr = this.duration; | |
console.info('%csave PlaybackPosition:', 'background: cyan', ct, dr, vi.csrfToken); | |
if (vi.contextWatchId !== contextWatchId) { | |
return; | |
} | |
if (Math.abs(ct - dr) < 3) { | |
return; | |
} | |
if (dr < 120) { | |
return; | |
} // 短い動画は記録しない | |
PlaybackPosition.record( | |
contextWatchId, | |
ct, | |
vi.csrfToken | |
).catch(e => { | |
window.console.warn('save playback fail', e); | |
}); | |
} | |
close() { | |
if (this.isPlaying) { | |
this._savePlaybackPosition(this._watchId, this.currentTime); | |
} | |
WatchInfoCacheDb.put(this._watchId, {currentTime: this.currentTime}); | |
if (Fullscreen.now()) { | |
Fullscreen.cancel(); | |
} | |
this.pause(); | |
this.hide(); | |
this._refresh(); | |
this.emit('close'); | |
global.emitter.emitAsync('DialogPlayerClose'); | |
} | |
_refresh() { | |
if (this._nicoVideoPlayer) { | |
this._nicoVideoPlayer.close(); | |
} | |
if (this._videoSession) { | |
this._videoSession.close(); | |
} | |
} | |
async _initializePlaylist() { | |
if (this._playlist) { | |
return; | |
} | |
const $container = this._view.appendTab('playlist', 'プレイリスト'); | |
this._playlist = new PlayList({ | |
loader: ThumbInfoLoader, | |
container: $container[0], | |
loop: this._playerConfig.props.playlistLoop | |
}); | |
this._playlist.on('command', this._onCommand.bind(this)); | |
this._playlist.on('update', _.debounce(this._onPlaylistStatusUpdate.bind(this), 100)); | |
if (PlayListSession.isExist()) { | |
this._playlist.restoreFromSession(); | |
} | |
this.emitResolve('playlist-ready'); | |
} | |
_initializeCommentPanel() { | |
if (this._commentPanel) { | |
return; | |
} | |
const $container = this._view.appendTab('comment', 'コメント'); | |
this._commentPanel = new CommentPanel({ | |
player: this, | |
$container: $container, | |
autoScroll: this._playerConfig.props.enableCommentPanelAutoScroll, | |
language: this._playerConfig.props.commentLanguage | |
}); | |
this._commentPanel.on('command', this._onCommand.bind(this)); | |
this._commentPanel.on('update', _.debounce(this._onCommentPanelStatusUpdate.bind(this), 100)); | |
this.emitResolve('commentpanel-ready'); | |
} | |
get isPlaylistEnable() { | |
return this._playlist && this._playlist.isEnable; | |
} | |
playNextVideo(options) { | |
if (!this._playlist || !this.isOpen) { | |
return; | |
} | |
let opt = this._videoWatchOptions.createForVideoChange(options); | |
let nextId = this._playlist.selectNext(); | |
if (nextId) { | |
this.open(nextId, opt); | |
} | |
} | |
playPreviousVideo(options) { | |
if (!this._playlist || !this.isOpen) { | |
return; | |
} | |
let opt = this._videoWatchOptions.createForVideoChange(options); | |
let prevId = this._playlist.selectPrevious(); | |
if (prevId) { | |
this.open(prevId, opt); | |
} | |
} | |
play() { | |
if (!this._state.isError && this._nicoVideoPlayer) { | |
this._nicoVideoPlayer.play().catch((e) => { | |
this._onVideoPlayStartFail(e); | |
}); | |
} | |
} | |
pause() { | |
if (!this._state.isError && this._nicoVideoPlayer) { | |
this._nicoVideoPlayer.pause(); | |
this._state.setPausing(); | |
} | |
} | |
get isPlaying() { | |
return this._state.isPlaying; | |
} | |
get paused() { | |
return this._nicoVideoPlayer ? this._nicoVideoPlayer.paused : true; | |
} | |
togglePlay() { | |
if (!this._state.isError && this._nicoVideoPlayer) { | |
if (this.isPlaying) { | |
this.pause(); | |
return; | |
} | |
this._nicoVideoPlayer.togglePlay().catch((e) => { | |
this._onVideoPlayStartFail(e); | |
}); | |
} | |
} | |
set volume(v) { | |
if (this._nicoVideoPlayer) { | |
this._nicoVideoPlayer.volume = v; | |
} | |
} | |
get volume() { | |
return this._playerConfig.props.volume; | |
} | |
async addChat(text, cmd, vpos = null, options = {}) { | |
if (!this._nicoVideoPlayer || | |
!this.threadLoader || | |
!this._state.isCommentReady || | |
this._state.isCommentPosting) { | |
return Promise.reject(); | |
} | |
if (!util.isLogin()) { | |
return Promise.reject(); | |
} | |
const threadId = this._threadInfo.threadId * 1; | |
if (this._threadInfo.force184 !== '1') { | |
cmd = cmd ? ('184 ' + cmd) : '184'; | |
} | |
Object.assign(options, {isMine: true, isUpdating: true, thead: threadId}); | |
vpos = (!isNaN(vpos) && typeof vpos === 'number') ? vpos : this._nicoVideoPlayer.vpos; | |
const nicoChat = this._nicoVideoPlayer.addChat(text, cmd, vpos, options); | |
this._state.isCommentPosting = true; | |
const lang = this._playerConfig.props.commentLanguage; | |
window.console.time('コメント投稿'); | |
const onSuccess = result => { | |
window.console.timeEnd('コメント投稿'); | |
nicoChat.isUpdating = false; | |
nicoChat.no = result.no; | |
this.execCommand('notify', 'コメント投稿成功'); | |
this._state.isCommentPosting = false; | |
this._threadInfo.blockNo = result.blockNo; | |
WatchInfoCacheDb.put(this._watchId, {comment: {text, cmd, vpos, options}}); | |
return Promise.resolve(result); | |
}; | |
const onFail = err => { | |
err = err || {}; | |
window.console.log('_onFail: ', err); | |
window.console.timeEnd('コメント投稿'); | |
nicoChat.isPostFail = true; | |
nicoChat.isUpdating = false; | |
this.execCommand('alert', err.message); | |
this._state.isCommentPosting = false; | |
if (err.blockNo && typeof err.blockNo === 'number') { | |
this._threadInfo.blockNo = err.blockNo; | |
} | |
return Promise.reject(err); | |
}; | |
const msgInfo = this._videoInfo.msgInfo; | |
return this.threadLoader.postChat(msgInfo, text, cmd, vpos, lang) | |
.then(onSuccess).catch(onFail); | |
} | |
get duration() { | |
if (!this._videoInfo) { | |
return 0; | |
} | |
return this._videoInfo.duration; | |
} | |
get bufferedRange() {return this._nicoVideoPlayer.bufferedRange;} | |
get nonFilteredChatList() {return this._nicoVideoPlayer.nonFilteredChatList;} | |
get chatList() {return this._nicoVideoPlayer.chatList;} | |
get playingStatus() { | |
if (!this._nicoVideoPlayer || !this._nicoVideoPlayer.isPlaying) { | |
return {}; | |
} | |
const session = { | |
playing: true, | |
watchId: this._watchId, | |
url: location.href, | |
currentTime: this._nicoVideoPlayer.currentTime | |
}; | |
const options = this._videoWatchOptions.createForSession(); | |
Object.keys(options).forEach(key => { | |
session[key] = session.hasOwnProperty(key) ? session[key] : options[key]; | |
}); | |
return session; | |
} | |
get watchId() { | |
return this._watchId; | |
} | |
get currentTab() { | |
return this._state.currentTab; | |
} | |
getId() { return this.id; } | |
getDuration() { return this.duration; } | |
getBufferedRange() { return this.bufferedRange; } | |
getNonFilteredChatList() { return this.nonFilteredChatList;} | |
getChatList() { return this.chatList; } | |
getPlayingStatus() { return this.playingStatus; } | |
getMymemory() { | |
return this._nicoVideoPlayer.getMymemory(); | |
} | |
} | |
class VideoHoverMenu { | |
constructor(...args) { | |
this.initialize(...args); | |
} | |
initialize(params) { | |
this._container = params.playerContainer; | |
this._state = params.playerState; | |
this._bound = {}; | |
this._bound.emitClose = | |
_.debounce(() => util.dispatchCommand(this._container, 'close'), 300); | |
this._initializeDom(); | |
} | |
async _initializeDom() { | |
const container = this._container; | |
util.$.html(VideoHoverMenu.__tpl__).appendTo(container); | |
this._view = container.querySelector('.hoverMenuContainer'); | |
const $mc = util.$(container.querySelectorAll('.menuItemContainer')); | |
$mc.on('contextmenu', | |
e => { e.preventDefault(); e.stopPropagation(); }); | |
$mc.on('click', this._onClick.bind(this)); | |
$mc.on('mousedown', this._onMouseDown.bind(this)); | |
global.emitter.on('hideHover', this._hideMenu.bind(this)); | |
await this._initializeMylistSelectMenu(); | |
} | |
async _initializeMylistSelectMenu() { | |
if (!util.isLogin()) { | |
return; | |
} | |
this._mylistApiLoader = MylistApiLoader; | |
this._mylistList = await this._mylistApiLoader.getMylistList(); | |
this._initializeMylistSelectMenuDom(); | |
} | |
_initializeMylistSelectMenuDom(mylistList) { | |
if (!util.isLogin()) { | |
return; | |
} | |
mylistList = mylistList || this._mylistList; | |
const menu = this._container.querySelector('.mylistSelectMenu'); | |
menu.addEventListener('wheel', e => e.stopPropagation(), {passive: true}); | |
const ul = document.createElement('ul'); | |
mylistList.forEach(mylist => { | |
const li = document.createElement('li'); | |
li.className = `folder${mylist.icon_id}`; | |
const icon = document.createElement('span'); | |
icon.className = 'mylistIcon command'; | |
Object.assign(icon.dataset, { | |
mylistId: mylist.id, | |
mylistName: mylist.name, | |
command: 'mylistOpen' | |
}); | |
icon.title = mylist.name + 'を開く'; | |
const link = document.createElement('a'); | |
link.className = 'mylistLink name command'; | |
link.textContent = mylist.name; | |
link.href = `https://www.nicovideo.jp/my/mylist/#/${mylist.id}`; | |
Object.assign(link.dataset, { | |
mylistId: mylist.id, | |
mylistName: mylist.name, | |
command: 'mylistAdd' | |
}); | |
li.append(icon, link); | |
ul.append(li); | |
}); | |
menu.querySelector('.mylistSelectMenuInner').append(ul); | |
} | |
_onMouseDown(e) { | |
e.stopPropagation(); | |
const target = e.target.closest('[data-command]'); | |
if (!target) { | |
return; | |
} | |
let command = target.dataset.command; | |
switch (command) { | |
case 'deflistAdd': | |
if (e.shiftKey) { | |
command = 'mylistWindow'; | |
} else { | |
command = e.which > 1 ? 'deflistRemove' : 'deflistAdd'; | |
} | |
util.dispatchCommand(target, command); | |
break; | |
case 'toggle-like': | |
util.dispatchCommand(target, command); | |
break; | |
case 'mylistAdd': { | |
command = (e.shiftKey || e.which > 1) ? 'mylistRemove' : 'mylistAdd'; | |
const {mylistId, mylistName} = target.dataset; | |
this._hideMenu(); | |
util.dispatchCommand(target, command, {mylistId, mylistName}); | |
break; | |
} | |
case 'mylistOpen': { | |
const mylistId = target.dataset.mylistId; | |
location.href = `https://www.nicovideo.jp/my/mylist/#/${mylistId}`; | |
break; | |
} | |
case 'close': | |
this._bound.emitClose(); | |
break; | |
default: | |
return; | |
} | |
} | |
_onClick(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
const target = e.target.closest('[data-command]'); | |
if (!target) { | |
return; | |
} | |
let {command, type, param} = target.dataset; | |
switch (type) { | |
case 'json': | |
case 'bool': | |
case 'number': | |
param = JSON.parse(param); | |
break; | |
} | |
switch (command) { | |
case 'deflistAdd': | |
case 'mylistAdd': | |
case 'mylistOpen': | |
case 'close': | |
this._hideMenu(); | |
break; | |
case 'mylistMenu': | |
if (e.shiftKey) { | |
util.dispatchCommand(target, 'mylistWindow'); | |
} | |
break; | |
case 'nop': | |
break; | |
default: | |
this._hideMenu(); | |
util.dispatchCommand(target, command, param); | |
break; | |
} | |
} | |
_hideMenu() { | |
if (!this._view.contains(document.activeElement)) { | |
return; | |
} | |
window.setTimeout(() => document.body.focus(), 0); | |
} | |
} | |
util.addStyle(` | |
.hoverMenuContainer { | |
user-select: none; | |
contain: style size; | |
} | |
.menuItemContainer { | |
box-sizing: border-box; | |
position: absolute; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 40000}; | |
overflow: visible; | |
will-change: transform, opacity; | |
user-select: none; | |
} | |
.menuItemContainer .menuButton { | |
width: 32px; | |
height:32px; | |
font-size: 24px; | |
background: #888; | |
color: #000; | |
border: 1px solid #666; | |
border-radius: 4px; | |
line-height: 30px; | |
white-space: nowrap; | |
text-align: center; | |
cursor: pointer; | |
outline: none; | |
} | |
.menuItemContainer:hover .menuButton { | |
pointer-events: auto; | |
} | |
.menuItemContainer.rightTop { | |
width: 240px; | |
height: 40px; | |
right: 0px; | |
top: 0; | |
perspective: 150px; | |
perspective-origin: center; | |
} | |
.menuItemContainer.rightTop .scalingUI { | |
transform-origin: right top; | |
} | |
.is-updatingDeflist .menuItemContainer.rightTop, | |
.is-updatingMylist .menuItemContainer.rightTop { | |
cursor: wait; | |
opacity: 1 !important; | |
} | |
.is-updatingDeflist .menuItemContainer.rightTop>*, | |
.is-updatingMylist .menuItemContainer.rightTop>* { | |
pointer-events: none; | |
} | |
.menuItemContainer.leftTop { | |
width: auto; | |
height: auto; | |
left: 32px; | |
top: 32px; | |
display: none; | |
} | |
.is-debug .menuItemContainer.leftTop { | |
display: inline-block !important; | |
opacity: 1 !important; | |
transition: none !important; | |
transform: translateZ(0); | |
max-width: 200px; | |
} | |
.menuItemContainer.leftBottom { | |
width: 120px; | |
height: 32px; | |
left: 8px; | |
bottom: 48px; | |
transform-origin: left bottom; | |
} | |
.menuItemContainer.rightBottom { | |
width: 120px; | |
height: 80px; | |
right: 0; | |
bottom: 8px; | |
} | |
.menuItemContainer.onErrorMenu { | |
position: absolute; | |
left: 50%; | |
top: 60%; | |
transform: translate(-50%, 0); | |
display: none; | |
white-space: nowrap; | |
} | |
.is-error .onErrorMenu { | |
display: block !important; | |
opacity: 1 !important; | |
} | |
.is-youTube .onErrorMenu .for-nicovideo, | |
.onErrorMenu .for-ZenTube { | |
display: none; | |
} | |
.is-youTube.is-error .onErrorMenu .for-ZenTube { | |
display: inline-block; | |
} | |
.onErrorMenu .menuButton { | |
position: relative; | |
display: inline-block !important; | |
margin: 0 16px; | |
padding: 8px; | |
background: #888; | |
color: #000; | |
opacity: 1; | |
cursor: pointer; | |
border-radius: 0; | |
box-shadow: 4px 4px 0 #333; | |
border: 2px outset; | |
width: 100px; | |
font-size: 14px; | |
line-height: 16px; | |
} | |
.menuItemContainer.onErrorMenu .menuButton:active { | |
background: var(--base-fore-color); | |
border: 2px inset; | |
} | |
.menuItemContainer.onErrorMenu .playNextVideo { | |
display: none !important; | |
} | |
.is-playlistEnable .menuItemContainer.onErrorMenu .playNextVideo { | |
display: inline-block !important; | |
} | |
.menuButton { | |
position: absolute; | |
opacity: 0; | |
transition: | |
opacity 0.4s ease, | |
box-shadow 0.2s ease 1s, | |
background 0.4s ease; | |
box-sizing: border-box; | |
text-align: center; | |
text-shadow: none; | |
user-select: none; | |
will-change: transform, opacity; | |
contain: style size layout; | |
} | |
.menuButton:focus-within, | |
.menuButton:hover { | |
box-shadow: 0 2px 0 #000; | |
cursor: pointer; | |
opacity: 1; | |
background: #888; | |
color: #000; | |
} | |
.menuButton:active { | |
transform: translate(0, 2px); | |
box-shadow: 0 0 0 #000; | |
transition: none; | |
} | |
.menuButton .tooltip { | |
display: none; | |
pointer-events: none; | |
position: absolute; | |
left: 16px; | |
top: -24px; | |
font-size: 12px; | |
line-height: 16px; | |
padding: 2px 4px; | |
border: 1px solid !000; | |
background: #ffc; | |
color: black; | |
box-shadow: 2px 2px 2px #fff; | |
text-shadow: none; | |
white-space: nowrap; | |
z-index: 100; | |
opacity: 0.8; | |
} | |
.menuButton:hover .tooltip { | |
display: block; | |
} | |
.menuButton:avtive .tooltip { | |
display: none; | |
} | |
.menuButtonInner { | |
will-change: opacity; | |
} | |
.menuButton:active .zenzaPopupMenu { | |
transform: translate(0, -2px); | |
transition: none; | |
} | |
.hoverMenuContainer .menuButton:focus-within { | |
pointer-events: none; | |
} | |
.hoverMenuContainer .menuButton:focus-within .zenzaPopupMenu, | |
.hoverMenuContainer .menuButton .zenzaPopupMenu:hover { | |
pointer-events: auto; | |
visibility: visible; | |
opacity: 0.99; | |
pointer-events: auto; | |
transition: opacity 0.3s; | |
} | |
.rightTop .menuButton .tooltip { | |
top: auto; | |
bottom: -24px; | |
right: -16px; | |
left: auto; | |
} | |
.rightBottom .menuButton .tooltip { | |
right: 16px; | |
left: auto; | |
} | |
.is-mouseMoving .menuButton { | |
opacity: 0.8; | |
background: rgba(80, 80, 80, 0.5); | |
border: 1px solid #888; | |
transition: box-shadow 0.2s ease; | |
} | |
.is-mouseMoving .menuButton .menuButtonInner { | |
opacity: 0.8; | |
word-break: normal; | |
transition: | |
box-shadow 0.2s ease, | |
background 0.4s ease; | |
} | |
.showCommentSwitch { | |
left: 0; | |
width: 32px; | |
height: 32px; | |
background:#888; | |
color: #000; | |
border: 1px solid #666; | |
line-height: 30px; | |
filter: grayscale(100%); | |
border-radius: 4px; | |
} | |
.is-showComment .showCommentSwitch { | |
color: #fff; | |
filter: none; | |
text-decoration: none; | |
} | |
.showCommentSwitch .menuButtonInner { | |
text-decoration: line-through; | |
} | |
.is-showComment .showCommentSwitch .menuButtonInner { | |
text-decoration: none; | |
} | |
.menuItemContainer .mylistButton { | |
font-size: 21px; | |
} | |
.mylistButton.mylistAddMenu { | |
left: 80px; | |
top: 0; | |
} | |
.mylistButton.deflistAdd { | |
left: 120px; | |
top: 0; | |
} | |
.zenzaTweetButton { | |
left: 40px; | |
} | |
@keyframes spinX { | |
0% { transform: rotateX(0deg); } | |
100% { transform: rotateX(1800deg); } | |
} | |
@keyframes spinY { | |
0% { transform: rotateY(0deg); } | |
100% { transform: rotateY(1800deg); } | |
} | |
.is-updatingDeflist .mylistButton.deflistAdd { | |
pointer-events: none; | |
opacity: 1 !important; | |
border: 1px inset !important; | |
box-shadow: none !important; | |
background: #888 !important; | |
color: #000 !important; | |
animation-name: spinX; | |
animation-iteration-count: infinite; | |
animation-duration: 6s; | |
animation-timing-function: linear; | |
} | |
.is-updatingDeflist .mylistButton.deflistAdd .tooltip { | |
display: none; | |
} | |
.mylistButton.mylistAddMenu:focus-within, | |
.is-updatingMylist .mylistButton.mylistAddMenu { | |
pointer-events: none; | |
opacity: 1 !important; | |
border: 1px inset #000 !important; | |
color: #000 !important; | |
box-shadow: none !important; | |
} | |
.mylistButton.mylistAddMenu:focus-within { | |
background: #888 !important; | |
} | |
.is-updatingMylist .mylistButton.mylistAddMenu { | |
background: #888 !important; | |
color: #000 !important; | |
animation-name: spinX; | |
animation-iteration-count: infinite; | |
animation-duration: 6s; | |
animation-timing-function: linear; | |
} | |
.mylistSelectMenu { | |
top: 36px; | |
right: -48px; | |
padding: 8px 0; | |
font-size: 13px; | |
backface-visibility: hidden; | |
} | |
.is-updatingMylist .mylistSelectMenu { | |
display: none; | |
} | |
.mylistSelectMenu .mylistSelectMenuInner { | |
overflow-y: auto; | |
overflow-x: hidden; | |
max-height: 50vh; | |
overscroll-behavior: none; | |
} | |
.mylistSelectMenu .triangle { | |
transform: rotate(135deg); | |
top: -8.5px; | |
right: 55px; | |
} | |
.mylistSelectMenu ul li { | |
line-height: 120%; | |
overflow-y: visible; | |
border-bottom: none; | |
} | |
.mylistSelectMenu .mylistIcon { | |
display: inline-block; | |
width: 18px; | |
height: 14px; | |
margin: -4px 4px 0 0; | |
vertical-align: middle; | |
margin-right: 15px; | |
background: url("//nicovideo.cdn.nimg.jp/uni/img/zero_my/icon_folder_default.png") no-repeat scroll 0 0 transparent; | |
transform: scale(1.5); | |
transform-origin: 0 0 0; | |
transition: transform 0.1s ease, box-shadow 0.1s ease; | |
cursor: pointer; | |
} | |
.mylistSelectMenu .mylistIcon:hover { | |
background-color: #ff9; | |
transform: scale(2); | |
} | |
.mylistSelectMenu .mylistIcon:hover::after { | |
background: #fff; | |
z-index: 100; | |
opacity: 1; | |
} | |
.mylistSelectMenu .deflist .mylistIcon { background-position: 0 -253px;} | |
.mylistSelectMenu .folder1 .mylistIcon { background-position: 0 -23px;} | |
.mylistSelectMenu .folder2 .mylistIcon { background-position: 0 -46px;} | |
.mylistSelectMenu .folder3 .mylistIcon { background-position: 0 -69px;} | |
.mylistSelectMenu .folder4 .mylistIcon { background-position: 0 -92px;} | |
.mylistSelectMenu .folder5 .mylistIcon { background-position: 0 -115px;} | |
.mylistSelectMenu .folder6 .mylistIcon { background-position: 0 -138px;} | |
.mylistSelectMenu .folder7 .mylistIcon { background-position: 0 -161px;} | |
.mylistSelectMenu .folder8 .mylistIcon { background-position: 0 -184px;} | |
.mylistSelectMenu .folder9 .mylistIcon { background-position: 0 -207px;} | |
.mylistSelectMenu .name { | |
display: inline-block; | |
width: calc(100% - 20px); | |
vertical-align: middle; | |
font-size: 110%; | |
color: #fff; | |
text-decoration: none !important; | |
} | |
.mylistSelectMenu .name:hover { | |
color: #fff; | |
} | |
.mylistSelectMenu .name::after { | |
content: ' に登録'; | |
font-size: 75%; | |
color: #333; | |
} | |
.mylistSelectMenu li:hover .name::after { | |
color: #fff; | |
} | |
.toggleLikeButton { | |
transition: | |
opacity 0.4s ease, | |
box-shadow 0.2s ease 1s, | |
transform 0.2s ease 1s; | |
} | |
.toggleLikeButton:hover { | |
text-shadow: 0 0 2px deeppink; | |
background: none; | |
color: pink; | |
} | |
.is-liked .toggleLikeButton { | |
color: pink; | |
} | |
.toggleLikeButton .liked-heart { | |
display: none; | |
} | |
.is-liked .toggleLikeButton .liked-heart { | |
display: block; | |
} | |
.is-liked .toggleLikeButton .not-liked-heart { | |
display: none; | |
} | |
.toggleLikeButton .heart-effect { | |
position: absolute; | |
left: 50%; top: 50%; | |
transform: translate(-50%, -50%) scale(5); | |
text-shadow: 0 0 3px deeppink; | |
color: #fff; | |
opacity: 0; | |
visibility: hidden; | |
transition: | |
transform 0.8s ease, | |
opacity 0.8s ease, | |
visibility 0.8s ease, | |
color 0.8s ease; | |
} | |
.toggleLikeButton:active .heart-effect { | |
transition: none; | |
transform: translate(-50%, -50%) scale(0.3); | |
color: pink; | |
opacity: 0.5; | |
visibility: visible; | |
} | |
.zenzaTweetButton:hover { | |
text-shadow: 1px 1px 2px #88c; | |
background: #1da1f2; | |
color: #fff; | |
} | |
.menuItemContainer .menuButton.closeButton { | |
position: absolute; | |
font-size: 20px; | |
top: 0; | |
right: 0; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 60000}; | |
margin: 0 0 40px 40px; | |
color: #ccc; | |
border: solid 1px #888; | |
border-radius: 0; | |
transition: | |
opacity 0.4s ease, | |
transform 0.2s ease, | |
background 0.2s ease, | |
box-shadow 0.2s ease | |
; | |
pointer-events: auto; | |
transform-origin: center center; | |
} | |
.is-mouseMoving .closeButton, | |
.closeButton:hover { | |
opacity: 1; | |
background: rgba(0, 0, 0, 0.8); | |
} | |
.closeButton:hover { | |
background: rgba(33, 33, 33, 0.9); | |
box-shadow: 4px 4px 4px #000; | |
} | |
.closeButton:active { | |
transform: scale(0.5); | |
} | |
.menuItemContainer .toggleDebugButton { | |
position: relative; | |
display: inline-block; | |
opacity: 1 !important; | |
padding: 8px 16px; | |
color: #000; | |
box-shadow: none; | |
font-size: 21px; | |
border: 1px solid black; | |
background: rgba(192, 192, 192, 0.8); | |
width: auto; | |
height: auto; | |
} | |
.togglePlayMenu { | |
display: none; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%) scale(1.5); | |
width: 80px; | |
height: 45px; | |
font-size: 35px; | |
line-height: 45px; | |
border-radius: 8px; | |
text-align: center; | |
color: var(--base-fore-color); | |
z-index: ${CONSTANT.BASE_Z_INDEX + 10}; | |
background: rgba(0, 0, 0, 0.8); | |
transition: transform 0.2s ease, box-shadow 0.2s, text-shadow 0.2s, font-size 0.2s; | |
box-shadow: 0 0 2px rgba(255, 255, 192, 0.8); | |
cursor: pointer; | |
} | |
.togglePlayMenu:hover { | |
transform: translate(-50%, -50%) scale(1.6); | |
text-shadow: 0 0 4px #888; | |
box-shadow: 0 0 8px rgba(255, 255, 255, 0.8); | |
} | |
.togglePlayMenu:active { | |
transform: translate(-50%, -50%) scale(2.0, 1.2); | |
font-size: 30px; | |
box-shadow: 0 0 4px inset rgba(0, 0, 0, 0.8); | |
text-shadow: none; | |
transition: transform 0.1s ease; | |
} | |
.is-notPlayed .togglePlayMenu { | |
display: block; | |
} | |
.is-playing .togglePlayMenu, | |
.is-error .togglePlayMenu, | |
.is-loading .togglePlayMenu { | |
display: none; | |
} | |
`, {className: 'videoHoverMenu'}); | |
util.addStyle(` | |
.menuItemContainer.leftBottom { | |
bottom: 64px; | |
} | |
.menuItemContainer.leftBottom .scalingUI { | |
transform-origin: left bottom; | |
} | |
.menuItemContainer.leftBottom .scalingUI { | |
height: 64px; | |
} | |
.menuItemContainer.rightBottom { | |
bottom: 64px; | |
} | |
.ngSettingSelectMenu { | |
bottom: 0px; | |
} | |
`, {className: 'videoHoverMenu screenMode for-full'}); | |
VideoHoverMenu.__tpl__ = (` | |
<div class="hoverMenuContainer"> | |
<div class="menuItemContainer leftTop"> | |
<div class="menuButton toggleDebugButton" data-command="toggle-debug"> | |
<div class="menuButtonInner">debug mode</div> | |
</div> | |
</div> | |
<div class="menuItemContainer rightTop"> | |
<div class="scalingUI"> | |
<div class="menuButton toggleLikeButton forMember" data-command="toggle-like"> | |
<div class="tooltip">いいね!</div> | |
<div class="menuButtonInner"><div class="not-liked-heart" | |
>♡</div><div class="liked-heart" | |
>♥</div><div class="heart-effect">♡</div></div> | |
</div> | |
<div class="menuButton zenzaTweetButton" data-command="tweet"> | |
<div class="tooltip">ツイート</div> | |
<div class="menuButtonInner">t</div> | |
</div> | |
<div class="menuButton mylistButton mylistAddMenu forMember" | |
data-command="nop" tabindex="-1" data-has-submenu="1"> | |
<div class="tooltip">マイリスト登録</div> | |
<div class="menuButtonInner">My</div> | |
<div class="mylistSelectMenu selectMenu zenzaPopupMenu forMember"> | |
<div class="triangle"></div> | |
<div class="mylistSelectMenuInner"> | |
</div> | |
</div> | |
</div> | |
<div class="menuButton mylistButton deflistAdd forMember" data-command="deflistAdd"> | |
<div class="tooltip">とりあえずマイリスト(T)</div> | |
<div class="menuButtonInner">✚</div> | |
</div> | |
<div class="menuButton closeButton" data-command="close"> | |
<div class="menuButtonInner">✖</div> | |
</div> | |
</div> | |
</div> | |
<div class="menuItemContainer leftBottom"> | |
<div class="scalingUI"> | |
<div class="showCommentSwitch menuButton" data-command="toggle-showComment"> | |
<div class="tooltip">コメント表示ON/OFF(V)</div> | |
<div class="menuButtonInner">💬</div> | |
</div> | |
</div> | |
</div> | |
<div class="menuItemContainer onErrorMenu"> | |
<div class="menuButton openGinzaMenu" data-command="openGinza"> | |
<div class="menuButtonInner">GINZAで視聴</div> | |
</div> | |
<div class="menuButton reloadMenu for-nicovideo" data-command="reload"> | |
<div class="menuButtonInner for-nicovideo">リロード</div> | |
<div class="menuButtonInner for-ZenTube">ZenTube解除</div> | |
</div> | |
<div class="menuButton playNextVideo" data-command="playNextVideo"> | |
<div class="menuButtonInner">次の動画</div> | |
</div> | |
</div> | |
<div class="togglePlayMenu menuItemContainer center" data-command="togglePlay"> | |
▶ | |
</div> | |
</div> | |
`).trim(); | |
class VariablesMapper { | |
get nextState() { | |
const {menuScale, commentLayerOpacity, fullscreenControlBarMode} = this.config.props; | |
return {menuScale, commentLayerOpacity, fullscreenControlBarMode}; | |
} | |
get videoControlBarHeight() { | |
return( | |
(VideoControlBar.BASE_HEIGHT - VideoControlBar.BASE_SEEKBAR_HEIGHT) * | |
this.state.menuScale + VideoControlBar.BASE_SEEKBAR_HEIGHT); | |
} | |
constructor({config, element}){ | |
this.config = config; | |
this.state = { | |
menuScale: 0, | |
commentLayerOpacity: 0, | |
fullscreenControlBarMode: 'auto' | |
}; | |
this.element = element || document.body; | |
this.emitter = new Emitter(); | |
const update = _.debounce(this.update.bind(this), 500); | |
Object.keys(this.state).forEach(key => | |
config.onkey(key, () => update(key))); | |
update(); | |
} | |
on(...args) { | |
this.emitter.on(...args); | |
} | |
shouldUpdate(state, nextState) { | |
return Object.keys(state).some(key => state[key] !== nextState[key]); | |
} | |
setVar(key, value) { | |
cssUtil.setProps([this.element, key, value]); } | |
update() { | |
const state = this.state; | |
const nextState = this.nextState; | |
if (!this.shouldUpdate(state, nextState)) { | |
return; | |
} | |
const {menuScale, commentLayerOpacity, fullscreenControlBarMode} = nextState; | |
this.state = nextState; | |
Object.assign(this.element.dataset, {fullscreenControlBarMode}); | |
if (state.scale !== menuScale) { | |
this.setVar('--zenza-ui-scale', menuScale); | |
this.setVar('--zenza-control-bar-height', css.px(this.videoControlBarHeight)); | |
} | |
if (state.commentLayerOpacity !== commentLayerOpacity) { | |
this.setVar('--zenza-comment-layer-opacity', commentLayerOpacity); | |
} | |
this.emitter.emit('update', nextState); | |
} | |
} | |
const RootDispatcher = (() => { | |
let config; | |
let player; | |
let playerState; | |
class RootDispatcher { | |
static initialize(dialog) { | |
player = dialog; | |
playerState = ZenzaWatch.state.player; | |
config = PlayerConfig.getInstance(config); | |
config.on('update', RootDispatcher.onConfigUpdate); | |
player.on('command', RootDispatcher.execCommand); | |
} | |
static execCommand(command, params) { | |
let result = {status: 'ok'}; | |
switch(command) { | |
case 'notifyHtml': | |
PopupMessage.notify(params, true); | |
break; | |
case 'notify': | |
PopupMessage.notify(params); | |
break; | |
case 'alert': | |
PopupMessage.alert(params); | |
break; | |
case 'alertHtml': | |
PopupMessage.alert(params, true); | |
break; | |
case 'copy-video-watch-url': | |
Clipboard.copyText(playerState.videoInfo.watchUrl); | |
break; | |
case 'tweet': | |
nicoUtil.openTweetWindow(playerState.videoInfo); | |
break; | |
case 'export-config': | |
config.exportToFile(); | |
break; | |
case 'toggleConfig': { | |
config.props[params] = !config.props[params]; | |
break; | |
} | |
case 'picture-in-picture': | |
document.querySelector('.zenzaWatchVideoElement').requestPictureInPicture(); | |
break; | |
case 'toggle-comment': | |
case 'toggle-showComment': | |
case 'toggle-backComment': | |
case 'toggle-mute': | |
case 'toggle-loop': | |
case 'toggle-debug': | |
case 'toggle-enableFilter': | |
case 'toggle-enableNicosJumpVideo': | |
case 'toggle-useWellKnownPort': | |
case 'toggle-bestZenTube': | |
case 'toggle-autoCommentSpeedRate': | |
case 'toggle-video.hls.enableOnlyRequired': | |
command = command.replace(/^toggle-/, ''); | |
config.props[command] = !config.props[command]; | |
break; | |
case 'baseFontFamily': | |
case 'baseChatScale': | |
case 'enableFilter': | |
case 'update-enableFilter': | |
case 'screenMode': | |
case 'update-screenMode': | |
case 'update-sharedNgLevel': | |
case 'update-commentSpeedRate': | |
case 'update-fullscreenControlBarMode': | |
command = command.replace(/^update-/, ''); | |
if (config.props[command] === params) { | |
break; | |
} | |
config.props[command] = params; | |
break; | |
case 'nop': | |
break; | |
case 'echo': | |
window.console.log('%cECHO', 'font-weight: bold;', {params}); | |
PopupMessage.notify(`ECHO: 「${typeof params === 'string' ? params : JSON.stringify(params)}」`); | |
break; | |
default: | |
ZenzaWatch.emitter.emit(`command-${command}`, command, params); | |
window.dispatchEvent(new CustomEvent(`${PRODUCT}-command`, {detail: {command, params, param: params}})); | |
} | |
return result; | |
} | |
static onConfigUpdate(key, value) { | |
switch (key) { | |
case 'enableFilter': | |
playerState.isEnableFilter = value; | |
break; | |
case 'backComment': | |
playerState.isBackComment = !!value; | |
break; | |
case 'showComment': | |
playerState.isShowComment = !!value; | |
break; | |
case 'loop': | |
playerState.isLoop = !!value; | |
break; | |
case 'mute': | |
playerState.isMute = !!value; | |
break; | |
case 'debug': | |
playerState.isDebug = !!value; | |
PopupMessage.notify('debug: ' + (value ? 'ON' : 'OFF')); | |
break; | |
case 'sharedNgLevel': | |
case 'screenMode': | |
case 'playbackRate': | |
playerState[key] = value; | |
break; | |
} | |
} | |
} | |
return RootDispatcher; | |
})(); | |
class CommentInputPanel extends Emitter { | |
constructor(params) { | |
super(); | |
this._$playerContainer = params.$playerContainer; | |
this.config = params.playerConfig; | |
this._initializeDom(); | |
this.config.onkey('autoPauseCommentInput', this._onAutoPauseCommentInputChange.bind(this)); | |
} | |
_initializeDom() { | |
let $container = this._$playerContainer; | |
let config = this.config; | |
css.addStyle(CommentInputPanel.__css__); | |
$container.append(uq.html(CommentInputPanel.__tpl__)); | |
let $view = this._$view = $container.find('.commentInputPanel'); | |
let $input = this._$input = $view.find('.commandInput, .commentInput'); | |
this._$form = $container.find('form'); | |
let $autoPause = this._$autoPause = $container.find('.autoPause'); | |
this._$commandInput = $container.find('.commandInput'); | |
let $cmt = this._$commentInput = $container.find('.commentInput'); | |
this._$commentSubmit = $container.find('.commentSubmit'); | |
let preventEsc = e => { | |
if (e.keyCode === 27) { // ESC | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.emit('esc'); | |
e.target.blur(); | |
} | |
}; | |
$input | |
.on('focus', this._onFocus.bind(this)) | |
.on('blur', _.debounce(this._onBlur.bind(this), 500)) | |
.on('keydown', preventEsc) | |
.on('keyup', preventEsc); | |
$autoPause.prop('checked', config.props.autoPauseCommentInput); | |
this._$autoPause.on('change', () => { | |
config.props.autoPauseCommentInput = !!$autoPause.prop('checked'); | |
$cmt.focus(); | |
}); | |
this._$view.find('label').on('click', e => e.stopPropagation()); | |
this._$form.on('submit', this._onSubmit.bind(this)); | |
this._$commentSubmit.on('click', this._onSubmitButtonClick.bind(this)); | |
$view.on('click', e => e.stopPropagation()).on('paste', e => e.stopPropagation()); | |
} | |
_onFocus() { | |
if (!this._hasFocus) { | |
this.emit('focus', this.isAutoPause); | |
} | |
this._hasFocus = true; | |
} | |
_onBlur() { | |
if (this._$commandInput.hasFocus() || this._$commentInput.hasFocus()) { | |
return; | |
} | |
this.emit('blur', this.isAutoPause); | |
this._hasFocus = false; | |
} | |
_onSubmit() { | |
this.submit(); | |
} | |
_onSubmitButtonClick() { | |
this.submit(); | |
} | |
_onAutoPauseCommentInputChange(val) { | |
this._$autoPause.prop('checked', !!val); | |
} | |
submit() { | |
let chat = this._$commentInput.val().trim(); | |
let cmd = this._$commandInput.val().trim(); | |
if (!chat.length) { | |
return; | |
} | |
setTimeout(() => { | |
this._$commentInput.val('').blur(); | |
this._$commandInput.blur(); | |
let $view = this._$view.addClass('updating'); | |
(new Promise((resolve, reject) => this.emit('post', {resolve, reject}, chat, cmd))) | |
.then(() => $view.removeClass('updating')) | |
.catch(() => $view.removeClass('updating')); | |
}, 0); | |
} | |
get isAutoPause() { | |
return this.config.props.autoPauseCommentInput; | |
} | |
focus() { | |
this._$commentInput.focus(); | |
this._onFocus(); | |
} | |
blur() { | |
this._$commandInput.blur(); | |
this._$commentInput.blur(); | |
this._onBlur(); | |
} | |
} | |
CommentInputPanel.__css__ = (` | |
.commentInputPanel { | |
position: fixed; | |
top: calc(-50vh + 50% + 100vh); | |
left: 50vw; | |
box-sizing: border-box; | |
width: 200px; | |
height: 50px; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 30000}; | |
transform: translate(-50%, -170px); | |
overflow: visible; | |
} | |
.is-notPlayed .commentInputPanel, | |
.is-waybackMode .commentInputPanel, | |
.is-mymemory .commentInputPanel, | |
.is-loading .commentInputPanel, | |
.is-error .commentInputPanel { | |
display: none; | |
} | |
.commentInputPanel:focus-within { | |
width: 500px; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 100000}; | |
} | |
.zenzaScreenMode_wide .commentInputPanel, | |
.is-fullscreen .commentInputPanel { | |
position: absolute !important; /* fixedだとFirefoxのバグで消える */ | |
top: auto !important; | |
bottom: 120px !important; | |
transform: translate(-50%, 0); | |
left: 50%; | |
} | |
.commentInputPanel>* { | |
pointer-events: none; | |
} | |
.commentInputPanel input { | |
font-size: 18px; | |
} | |
.commentInputPanel:focus-within>*, | |
.commentInputPanel:hover>* { | |
pointer-events: auto; | |
} | |
.is-mouseMoving .commentInputOuter { | |
border: 1px solid #888; | |
box-sizing: border-box; | |
border-radius: 8px; | |
opacity: 0.5; | |
} | |
.is-mouseMoving:not(:focus-within) .commentInputOuter { | |
box-shadow: 0 0 8px #fe9, 0 0 4px #fe9 inset; | |
} | |
.commentInputPanel:focus-within .commentInputOuter, | |
.commentInputPanel:hover .commentInputOuter { | |
border: none; | |
opacity: 1; | |
} | |
.commentInput { | |
width: 100%; | |
height: 30px !important; | |
font-size: 24px; | |
background: transparent; | |
border: none; | |
opacity: 0; | |
transition: opacity 0.3s ease, box-shadow 0.4s ease; | |
text-align: center; | |
line-height: 26px !important; | |
padding-right: 32px !important; | |
margin-bottom: 0 !important; | |
} | |
.commentInputPanel:hover .commentInput { | |
opacity: 0.5; | |
} | |
.commentInputPanel:focus-within .commentInput { | |
opacity: 0.9 !important; | |
} | |
.commentInputPanel:focus-within .commentInput, | |
.commentInputPanel:hover .commentInput { | |
box-sizing: border-box; | |
border: 1px solid #888; | |
border-radius: 8px; | |
background: #fff; | |
box-shadow: 0 0 8px #fff; | |
} | |
.commentInputPanel .autoPauseLabel { | |
display: none; | |
} | |
.commentInputPanel:focus-within .autoPauseLabel { | |
position: absolute; | |
top: 36px; | |
left: 50%; | |
transform: translate(-50%, 0); | |
display: block; | |
background: #336; | |
z-index: 100; | |
color: #ccc; | |
padding: 0 8px; | |
} | |
.commandInput { | |
position: absolute; | |
width: 100px; | |
height: 30px !important; | |
font-size: 24px; | |
top: 0; | |
left: 0; | |
border-radius: 8px; | |
z-index: -1; | |
opacity: 0; | |
transition: left 0.2s ease, opacity 0.2s ease; | |
text-align: center; | |
line-height: 26px !important; | |
padding: 0 !important; | |
margin-bottom: 0 !important; | |
} | |
.commentInputPanel:focus-within .commandInput { | |
left: -108px; | |
z-index: 1; | |
opacity: 0.9; | |
border: none; | |
pointer-evnets: auto; | |
box-shadow: 0 0 8px #fff; | |
padding: 0; | |
} | |
.commentSubmit { | |
position: absolute; | |
width: 100px !important; | |
height: 30px !important; | |
font-size: 24px; | |
top: 0; | |
right: 0; | |
border: none; | |
border-radius: 8px; | |
z-index: -1; | |
opacity: 0; | |
transition: right 0.2s ease, opacity 0.2s ease; | |
line-height: 26px; | |
letter-spacing: 0.2em; | |
} | |
.commentInputPanel:focus-within .commentSubmit { | |
right: -108px; | |
z-index: 1; | |
opacity: 0.9; | |
box-shadow: 0 0 8px #fff; | |
} | |
.commentInputPanel:focus-within .commentSubmit:active { | |
color: #000; | |
background: #fff; | |
box-shadow: 0 0 16px #ccf; | |
} | |
`).trim(); | |
CommentInputPanel.__tpl__ = (` | |
<div class="commentInputPanel forMember" autocomplete="new-password"> | |
<form action="javascript: void(0);"> | |
<div class="commentInputOuter"> | |
<input | |
type="text" | |
value="" | |
autocomplete="on" | |
name="mail" | |
placeholder="コマンド" | |
class="commandInput" | |
maxlength="30" | |
> | |
<input | |
type="text" | |
value="" | |
autocomplete="off" | |
name="chat" | |
accesskey="c" | |
placeholder="コメント入力(C)" | |
class="commentInput" | |
maxlength="75" | |
> | |
<input | |
type="submit" | |
value="送信" | |
name="post" | |
class="commentSubmit" | |
> | |
<div class="recButton" title="音声入力"> | |
</div> | |
</div> | |
</form> | |
<label class="autoPauseLabel"> | |
<input type="checkbox" class="autoPause" checked="checked"> | |
入力時に一時停止 | |
</label> | |
</div> | |
`).trim(); | |
class TagListView extends BaseViewComponent { | |
constructor({parentNode}) { | |
super({ | |
parentNode, | |
name: 'TagListView', | |
template: '<div class="TagListView"></div>', | |
shadow: TagListView.__shadow__, | |
css: TagListView.__css__ | |
}); | |
this._state = { | |
isInputing: false, | |
isUpdating: false, | |
isEditing: false | |
}; | |
this._tagEditApi = new TagEditApi(); | |
} | |
_initDom(...args) { | |
super._initDom(...args); | |
const v = this._shadow || this._view; | |
Object.assign(this._elm, { | |
videoTags: v.querySelector('.videoTags'), | |
videoTagsInner: v.querySelector('.videoTagsInner'), | |
tagInput: v.querySelector('.tagInputText'), | |
form: v.querySelector('form') | |
}); | |
this._elm.tagInput.addEventListener('keydown', this._onTagInputKeyDown.bind(this)); | |
this._elm.form.addEventListener('submit', this._onTagInputSubmit.bind(this)); | |
v.addEventListener('keydown', e => { | |
if (this._state.isInputing) { | |
e.stopPropagation(); | |
} | |
}); | |
v.addEventListener('click', e => e.stopPropagation()); | |
ZenzaWatch.emitter.on('hideHover', () => { | |
if (this._state.isEditing) { | |
this._endEdit(); | |
} | |
}); | |
} | |
_onCommand(command, param) { | |
switch (command) { | |
case 'refresh': | |
this._refreshTag(); | |
break; | |
case 'toggleEdit': | |
if (this._state.isEditing) { | |
this._endEdit(); | |
} else { | |
this._beginEdit(); | |
} | |
break; | |
case 'toggleInput': | |
if (this._state.isInputing) { | |
this._endInput(); | |
} else { | |
this._beginInput(); | |
} | |
break; | |
case 'beginInput': | |
this._beginInput(); | |
break; | |
case 'endInput': | |
this._endInput(); | |
break; | |
case 'addTag': | |
this._addTag(param); | |
break; | |
case 'removeTag': { | |
let elm = this._elm.videoTags.querySelector(`.tagItem[data-tag-id="${param}"]`); | |
if (!elm) { | |
return; | |
} | |
elm.classList.add('is-Removing'); | |
let data = JSON.parse(elm.getAttribute('data-tag')); | |
this._removeTag(param, data.tag); | |
break; | |
} | |
case 'tag-search': | |
this._onTagSearch(param); | |
break; | |
default: | |
super._onCommand(command, param); | |
break; | |
} | |
} | |
_onTagSearch(word) { | |
const config = Config.namespace('videoSearch'); | |
let option = { | |
searchType: config.getValue('mode'), | |
order: config.getValue('order'), | |
sort: config.getValue('sort') || 'playlist', | |
owner: config.getValue('ownerOnly') | |
}; | |
if (option.sort === 'playlist') { | |
option.sort = 'f'; | |
option.playlistSort = true; | |
} | |
super._onCommand('playlistSetSearchVideo', {word, option}); | |
} | |
update({tagList = [], watchId = null, videoId = null, token = null, watchAuthKey = null}) { | |
if (watchId) { | |
this._watchId = watchId; | |
} | |
if (videoId) { | |
this._videoId = videoId; | |
} | |
if (token) { | |
this._token = token; | |
} | |
if (watchAuthKey) { | |
this._watchAuthKey = watchAuthKey; | |
} | |
this.setState({ | |
isInputing: false, | |
isUpdating: false, | |
isEditing: false, | |
isEmpty: false | |
}); | |
this._update(tagList); | |
this._boundOnBodyClick = this._onBodyClick.bind(this); | |
} | |
_onClick(e) { | |
if (this._state.isInputing || this._state.isEditing) { | |
e.stopPropagation(); | |
} | |
super._onClick(e); | |
} | |
_update(tagList = []) { | |
let tags = []; | |
tagList.forEach(tag => { | |
tags.push(this._createTag(tag)); | |
}); | |
tags.push(this._createToggleInput()); | |
this.setState({isEmpty: tagList.length < 1}); | |
this._elm.videoTagsInner.innerHTML = tags.join(''); | |
} | |
_createToggleInput() { | |
return (` | |
<div | |
class="button command toggleInput" | |
data-command="toggleInput" | |
data-tooltip="タグ追加"> | |
<span class="icon">⊕</span> | |
</div>`).trim(); | |
} | |
_onApiResult(watchId, result) { | |
if (watchId !== this._watchId) { | |
return; // 通信してる間に動画変わったぽい | |
} | |
const err = result.error_msg; | |
if (err) { | |
this.emit('command', 'alert', err); | |
} | |
this.update(result.tags); | |
} | |
_addTag(tag) { | |
this.setState({isUpdating: true}); | |
const wait3s = this._makeWait(3000); | |
const watchId = this._watchId; | |
const videoId = this._videoId; | |
const csrfToken = this._token; | |
const watchAuthKey = this._watchAuthKey; | |
const addTag = () => { | |
return this._tagEditApi.add({ | |
videoId, | |
tag, | |
csrfToken, | |
watchAuthKey | |
}); | |
}; | |
return Promise.all([addTag(), wait3s]).then(results => { | |
let result = results[0]; | |
if (watchId !== this._watchId) { | |
return; | |
} // 待ってる間に動画が変わったぽい | |
if (result && result.tags) { | |
this._update(result.tags); | |
} | |
this.setState({isInputing: false, isUpdating: false, isEditing: false}); | |
if (result.error_msg) { | |
this.emit('command', 'alert', result.error_msg); | |
} | |
}); | |
} | |
_removeTag(tagId, tag = '') { | |
this.setState({isUpdating: true}); | |
const wait3s = this._makeWait(3000); | |
const watchId = this._watchId; | |
const videoId = this._videoId; | |
const csrfToken = this._token; | |
const watchAuthKey = this._watchAuthKey; | |
const removeTag = () => { | |
return this._tagEditApi.remove({ | |
videoId, | |
tag, | |
id: tagId, | |
csrfToken, | |
watchAuthKey | |
}); | |
}; | |
return Promise.all([removeTag(), wait3s]).then((results) => { | |
let result = results[0]; | |
if (watchId !== this._watchId) { | |
return; | |
} // 待ってる間に動画が変わったぽい | |
if (result && result.tags) { | |
this._update(result.tags); | |
} | |
this.setState({isUpdating: false}); | |
if (result.error_msg) { | |
this.emit('command', 'alert', result.error_msg); | |
} | |
}); | |
} | |
_refreshTag() { | |
this.setState({isUpdating: true}); | |
const watchId = this._watchId; | |
const wait1s = this._makeWait(1000); | |
const load = () => { | |
return this._tagEditApi.load(this._videoId); | |
}; | |
return Promise.all([load(), wait1s]).then((results) => { | |
let result = results[0]; | |
if (watchId !== this._watchId) { | |
return; | |
} // 待ってる間に動画が変わったぽい | |
this._update(result.tags); | |
this.setState({isUpdating: false, isInputing: false, isEditing: false}); | |
}); | |
} | |
_makeWait(ms) { | |
return new Promise(resolve => { | |
setTimeout(() => { | |
resolve(ms); | |
}, ms); | |
}); | |
} | |
_createDicIcon(text, hasDic) { | |
let href = `https://dic.nicovideo.jp/a/${encodeURIComponent(text)}`; | |
let src = hasDic ? | |
'https://live.nicovideo.jp/img/2012/watch/tag_icon002.png' : | |
'https://live.nicovideo.jp/img/2012/watch/tag_icon003.png' ; | |
let icon = `<img class="dicIcon" src="${src}">`; | |
let hasNicodic = hasDic ? 1 : 0; | |
return ( | |
`<zenza-tag-item-menu | |
class="tagItemMenu" | |
data-text="${encodeURIComponent(text)}" | |
data-has-nicodic="${hasNicodic}" | |
><a target="_blank" class="nicodic" href="${href}">${icon}</a></zenza-tag-item-menu>` | |
); | |
} | |
_createDeleteButton(id) { | |
return `<span target="_blank" class="deleteButton command" title="削除" data-command="removeTag" data-param="${id}">ー</span>`; | |
} | |
_createLink(text) { | |
let href = `//www.nicovideo.jp/tag/${encodeURIComponent(text)}`; | |
text = textUtil.escapeToZenkaku(textUtil.unescapeHtml(text)); | |
return `<a class="tagLink" href="${href}">${text}</a>`; | |
} | |
_createSearch(text) { | |
let title = 'プレイリストに追加'; | |
let command = 'tag-search'; | |
let param = textUtil.escapeHtml(text); | |
return (`<zenza-playlist-append class="playlistAppend" title="${title}" data-command="${command}" data-param="${param}">▶</zenza-playlist-append>`); | |
} | |
_createTag(tag) { | |
let text = tag.tag; | |
let dic = this._createDicIcon(text, !!tag.dic); | |
let del = this._createDeleteButton(tag.id); | |
let link = this._createLink(text); | |
let search = this._createSearch(text); | |
let data = textUtil.escapeHtml(JSON.stringify(tag)); | |
let className = (tag.lock || tag.owner_lock === 1 || tag.lck === '1') ? 'tagItem is-Locked' : 'tagItem'; | |
className = (tag.cat) ? `${className} is-Category` : className; | |
return `<li class="${className}" data-tag="${data}" data-tag-id="${tag.id}">${dic}${del}${link}${search}</li>`; | |
} | |
_onTagInputKeyDown(e) { | |
if (this._state.isUpdating) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
} | |
switch (e.keyCode) { | |
case 27: // ESC | |
e.preventDefault(); | |
e.stopPropagation(); | |
this._endInput(); | |
break; | |
} | |
} | |
_onTagInputSubmit(e) { | |
if (this._state.isUpdating) { | |
return; | |
} | |
e.preventDefault(); | |
e.stopPropagation(); | |
let val = (this._elm.tagInput.value || '').trim(); | |
if (!val) { | |
this._endInput(); | |
return; | |
} | |
this._onCommand('addTag', val); | |
this._elm.tagInput.value = ''; | |
} | |
_onBodyClick() { | |
this._endInput(); | |
this._endEdit(); | |
} | |
_beginEdit() { | |
this.setState({isEditing: true}); | |
document.body.addEventListener('click', this._boundOnBodyClick); | |
} | |
_endEdit() { | |
document.body.removeEventListener('click', this._boundOnBodyClick); | |
this.setState({isEditing: false}); | |
} | |
_beginInput() { | |
this.setState({isInputing: true}); | |
document.body.addEventListener('click', this._boundOnBodyClick); | |
this._elm.tagInput.value = ''; | |
window.setTimeout(() => { | |
this._elm.tagInput.focus(); | |
}, 100); | |
} | |
_endInput() { | |
this._elm.tagInput.blur(); | |
document.body.removeEventListener('click', this._boundOnBodyClick); | |
this.setState({isInputing: false}); | |
} | |
} | |
TagListView.__shadow__ = (` | |
<style> | |
:host-context(.videoTagsContainer.sideTab) .tagLink { | |
color: #000 !important; | |
text-decoration: none; | |
} | |
.TagListView { | |
position: relative; | |
user-select: none; | |
} | |
.TagListView.is-Updating { | |
cursor: wait; | |
} | |
:host-context(.videoTagsContainer.sideTab) .TagListView.is-Updating { | |
overflow: hidden; | |
} | |
.TagListView.is-Updating:after { | |
content: '${'\\0023F3'}'; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
text-align: center; | |
transform: translate(-50%, -50%); | |
z-index: 10001; | |
color: #fe9; | |
font-size: 24px; | |
letter-spacing: 3px; | |
text-shadow: 0 0 4px #000; | |
pointer-events: none; | |
} | |
.TagListView.is-Updating:before { | |
content: ' '; | |
background: rgba(0, 0, 0, 0.6); | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
width: 100%; | |
height: 100%; | |
padding: 8px; | |
z-index: 10000; | |
box-shadow: 0 0 8px #000; | |
border-radius: 8px; | |
pointer-events: none; | |
} | |
.TagListView.is-Updating * { | |
pointer-events: none; | |
} | |
*[data-tooltip] { | |
position: relative; | |
} | |
.TagListView .button { | |
position: relative; | |
display: inline-block; | |
min-width: 40px; | |
min-height: 24px; | |
cursor: pointer; | |
user-select: none; | |
transition: 0.2s transform, 0.2s box-shadow, 0.2s background; | |
text-align: center; | |
} | |
.TagListView .button:hover { | |
background: #666; | |
} | |
.TagListView .button:active { | |
transition: none; | |
box-shadow: 0 0 2px #000 inset; | |
} | |
.TagListView .button .icon { | |
position: absolute; | |
display: inline-block; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
} | |
.TagListView *[data-tooltip]:hover:after { | |
content: attr(data-tooltip); | |
position: absolute; | |
left: 50%; | |
bottom: 100%; | |
transform: translate(-50%, 0) scale(0.9); | |
pointer-events: none; | |
background: rgba(192, 192, 192, 0.9); | |
box-shadow: 0 0 4px #000; | |
color: black; | |
font-size: 12px; | |
margin: 0; | |
padding: 2px 4px; | |
white-space: nowrap; | |
z-index: 10000; | |
letter-spacing: 2px; | |
} | |
.videoTags { | |
display: inline-block; | |
padding: 0; | |
} | |
.videoTagsInner { | |
display: flex; | |
flex-wrap: wrap; | |
padding: 0 8px; | |
} | |
.TagListView .tagItem { | |
position: relative; | |
list-style-type: none; | |
display: inline-flex; | |
margin-right: 2px; | |
line-height: 20px; | |
max-width: 50vw; | |
align-items: center; | |
} | |
.TagListView .tagItem:first-child { | |
margin-left: 100px; | |
} | |
.tagLink { | |
color: #fff; | |
text-decoration: none; | |
user-select: none; | |
display: inline-block; | |
border: 1px solid rgba(0, 0, 0, 0); | |
} | |
.TagListView .nicodic { | |
display: inline-block; | |
margin-right: 4px; | |
line-height: 20px; | |
cursor: pointer; | |
vertical-align: middle; | |
} | |
.TagListView.is-Editing .tagItemMenu, | |
.TagListView.is-Editing .nicodic, | |
.TagListView:not(.is-Editing) .deleteButton { | |
display: none !important; | |
} | |
.TagListView .deleteButton { | |
display: inline-block; | |
margin: 0px; | |
line-height: 20px; | |
width: 20px; | |
height: 20px; | |
font-size: 16px; | |
background: #f66; | |
color: #fff; | |
cursor: pointer; | |
border-radius: 100%; | |
transition: transform 0.2s, background 0.4s; | |
text-shadow: none; | |
transform: scale(1.2); | |
text-align: center; | |
opacity: 0.8; | |
} | |
.TagListView.is-Editing .deleteButton:hover { | |
transform: rotate(0) scale(1.2); | |
background: #f00; | |
opacity: 1; | |
} | |
.TagListView.is-Editing .deleteButton:active { | |
transform: rotate(360deg) scale(1.2); | |
transition: none; | |
background: #888; | |
} | |
.TagListView.is-Editing .is-Locked .deleteButton { | |
visibility: hidden; | |
} | |
.TagListView .is-Removing .deleteButton { | |
background: #666; | |
} | |
.tagItem .playlistAppend { | |
display: inline-block; | |
position: relative; | |
left: auto; | |
bottom: auto; | |
} | |
.TagListView .tagItem .playlistAppend { | |
display: inline-block; | |
font-size: 16px; | |
line-height: 24px; | |
width: 24px; | |
height: 24px; | |
bottom: 4px; | |
background: #666; | |
color: #ccc; | |
text-decoration: none; | |
border: 1px outset; | |
cursor: pointer; | |
text-align: center; | |
user-select: none; | |
visibility: hidden; | |
margin-right: -2px; | |
} | |
.tagItem:hover .playlistAppend { | |
visibility: visible; | |
} | |
.tagItem:hover .playlistAppend:hover { | |
transform: scale(1.5); | |
} | |
.tagItem:hover .playlistAppend:active { | |
transform: scale(1.4); | |
} | |
.tagItem.is-Removing { | |
transform-origin: right !important; | |
transform: translate(0, 150vh) !important; | |
opacity: 0 !important; | |
max-width: 0 !important; | |
transition: | |
transform 2s ease 0.2s, | |
opacity 1.5s linear 0.2s, | |
max-width 0.5s ease 1.5s | |
!important; | |
pointer-events: none; | |
overflow: hidden !important; | |
white-space: nowrap; | |
} | |
.is-Editing .playlistAppend { | |
visibility: hidden !important; | |
} | |
.is-Editing .tagLink { | |
pointer-events: none; | |
} | |
.is-Editing .dicIcon { | |
display: none; | |
} | |
.tagItem:not(.is-Locked) { | |
transition: transform 0.2s, text-shadow 0.2s; | |
} | |
.is-Editing .tagItem.is-Locked { | |
position: relative; | |
cursor: not-allowed; | |
} | |
.is-Editing .tagItem.is-Locked *{ | |
pointer-events: none; | |
} | |
.is-Editing .tagItem.is-Locked:hover:after { | |
content: '${'\\01F6AB'} ロックタグ'; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
color: #ff9; | |
white-space: nowrap; | |
background: rgba(0, 0, 0, 0.6); | |
} | |
.is-Editing .tagItem:nth-child(11).is-Locked:hover:after { | |
content: '${'\\01F6AB'} ロックマン'; | |
} | |
.is-Editing .tagItem:not(.is-Locked) { | |
text-shadow: 0 4px 4px rgba(0, 0, 0, 0.8); | |
} | |
.is-Editing .tagItem.is-Category * { | |
color: #ff9; | |
} | |
.is-Editing .tagItem.is-Category.is-Locked:hover:after { | |
content: '${'\\01F6AB'} カテゴリタグ'; | |
} | |
.tagInputContainer { | |
display: none; | |
padding: 4px 8px; | |
background: #666; | |
z-index: 5000; | |
box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.8); | |
font-size: 16px; | |
} | |
:host-context(.videoTagsContainer.sideTab) .tagInputContainer { | |
position: absolute; | |
background: #999; | |
} | |
.tagInputContainer .tagInputText { | |
width: 200px; | |
font-size: 20px; | |
} | |
.tagInputContainer .submit { | |
font-size: 20px; | |
} | |
.is-Inputing .tagInputContainer { | |
display: inline-block; | |
} | |
.is-Updating .tagInputContainer { | |
pointer-events: none; | |
} | |
.tagInput { | |
border: 1px solid; | |
} | |
.tagInput:active { | |
box-shadow: 0 0 4px #fe9; | |
} | |
.submit, .cancel { | |
background: #666; | |
color: #ccc; | |
cursor: pointer; | |
border: 1px solid; | |
text-align: center; | |
} | |
.TagListView .tagEditContainer { | |
position: absolute; | |
left: 0; | |
top: 0; | |
z-index: 1000; | |
display: inline-block; | |
} | |
.TagListView.is-Empty .tagEditContainer { | |
position: relative; | |
} | |
.TagListView:hover .tagEditContainer { | |
display: inline-block; | |
} | |
.TagListView.is-Updating .tagEditContainer * { | |
pointer-events: none; | |
} | |
.TagListView .tagEditContainer .button, | |
.TagListView .videoTags .button { | |
border-radius: 16px; | |
font-size: 24px; | |
line-height: 24px; | |
margin: 0; | |
} | |
.TagListView.is-Editing .button.toggleEdit, | |
.TagListView .button.toggleEdit:hover { | |
background: #c66; | |
} | |
.TagListView .button.tagRefresh .icon { | |
transform: translate(-50%, -50%) rotate(90deg); | |
transition: transform 0.2s ease; | |
font-family: STIXGeneral; | |
} | |
.TagListView .button.tagRefresh:active .icon { | |
transform: translate(-50%, -50%) rotate(-330deg); | |
transition: none; | |
} | |
.TagListView.is-Inputing .button.toggleInput { | |
display: none; | |
} | |
.TagListView .button.toggleInput:hover { | |
background: #66c; | |
} | |
.tagEditContainer form { | |
display: inline; | |
} | |
</style> | |
<div class="root TagListView"> | |
<div class="tagEditContainer"> | |
<div | |
class="button command toggleEdit" | |
data-command="toggleEdit" | |
data-tooltip="タグ編集"> | |
<span class="icon">✏</span> | |
</div> | |
<div class="button command tagRefresh" | |
data-command="refresh" | |
data-tooltip="リロード"> | |
<span class="icon">↻</span> | |
</div> | |
</div> | |
<div class="videoTags"> | |
<span class="videoTagsInner"></span> | |
<div class="tagInputContainer"> | |
<form action="javascript: void"> | |
<input type="text" name="tagText" class="tagInputText"> | |
<button class="submit button">O K</button> | |
</form> | |
</div> | |
</div> | |
</div> | |
`).trim(); | |
TagListView.__css__ = (` | |
/* Firefox用 ShaowDOMサポートしたら不要 */ | |
.videoTagsContainer.sideTab .is-Updating { | |
overflow: hidden; | |
} | |
.videoTagsContainer.sideTab a { | |
color: #000 !important; | |
text-decoration: none !important; | |
} | |
.videoTagsContainer.videoHeader a { | |
color: #fff !important; | |
text-decoration: none !important; | |
} | |
.videoTagsContainer.sideTab .tagInputContainer { | |
position: absolute; | |
} | |
`).trim(); | |
class TagItemMenu extends HTMLElement { | |
static template({text}) { | |
let host = location.host; | |
return ` | |
<style> | |
.root { | |
display: inline-block; | |
--icon-size: 16px; | |
margin-right: 4px; | |
outline: none; | |
} | |
.icon { | |
position: relative; | |
display: inline-block; | |
vertical-align: middle; | |
box-sizing: border-box; | |
width: var(--icon-size); | |
height: var(--icon-size); | |
margin: 0; | |
padding: 0; | |
font-size: var(--icon-size); | |
line-height: calc(var(--icon-size)); | |
text-align: center; | |
cursor: pointer; | |
} | |
.nicodic, .toggle { | |
background: #888; | |
color: #ccc; | |
box-shadow: 0.1em 0.1em 0 #333; | |
} | |
.has-nicodic .nicodic,.has-nicodic .toggle { | |
background: #900; | |
} | |
.toggle::after { | |
content: '?'; | |
position: absolute; | |
width: var(--icon-size); | |
left: 0; | |
font-size: 0.8em; | |
font-weight: bolder; | |
} | |
.menu { | |
display: none; | |
position: fixed; | |
background-clip: content-box; | |
border-style: solid; | |
border-width: 16px 0 16px 0; | |
border-color: transparent; | |
padding: 0; | |
z-index: 100; | |
transform: translateY(-30px); | |
} | |
:host-context(.zenzaWatchVideoInfoPanelFoot) .menu { | |
position: absolute; | |
bottom: 0; | |
transform: translateY(8x); | |
} | |
.root .menu:hover, | |
.root:focus-within .menu { | |
display: inline-block; | |
} | |
li { | |
list-style-type: none; | |
padding: 2px 8px 2px 20px; | |
background: rgba(80, 80, 80, 0.95); | |
} | |
li a { | |
display: inline-block; | |
white-space: nowrap; | |
text-decoration: none; | |
color: #ccc; | |
} | |
li a:hover { | |
text-decoration: underline; | |
} | |
</style> | |
<div class="root" tabindex="-1"> | |
<div class="icon toggle"></div> | |
<ul class="menu"> | |
<li> | |
<a href="//dic.nicovideo.jp/a/${text}" | |
${host !== 'dic.nicovideo.jp' ? 'target="_blank"' : ''}> | |
大百科を見る | |
</a> | |
</li> | |
<li> | |
<a href="//ch.nicovideo.jp/search/${text}?type=video&mode=t" | |
${host !== 'ch.nicovideo.jp' ? 'target="_blank"' : ''}> | |
チャンネル検索 | |
</a> | |
</li> | |
<li> | |
<a href="https://www.google.co.jp/search?q=${text}%20site:www.nicovideo.jp&num=100&tbm=vid" | |
${host !== 'www.google.co.jp' ? 'target="_blank"' : ''}> | |
Googleで検索 | |
</a> | |
</li> | |
<li> | |
<a href="https://www.bing.com/videos/search?q=${text}%20site:www.nicovideo.jp&qft=+filterui:msite-nicovideo.jp" | |
${host !== 'www.bing.com' ? 'target="_blank"' : ''}>Bingで検索 | |
</a> | |
</li> | |
<li> | |
<a href="https://www.google.co.jp/search?q=${text}%20site:www.nicovideo.jp/series&num=100" | |
${host !== 'www.google.co.jp' ? 'target="_blank"' : ''}> | |
シリーズ検索 | |
</a> | |
</li> | |
</ul> | |
</div> | |
`; | |
} | |
constructor() { | |
super(); | |
this.hasNicodic = parseInt(this.dataset.hasNicodic) !== 0; | |
this.text = textUtil.escapeToZenkaku(this.dataset.text); | |
const shadow = this._shadow = this.attachShadow({mode: 'open'}); | |
shadow.innerHTML = this.constructor.template({text: this.text}); | |
shadow.querySelector('.root').classList.toggle('has-nicodic', this.hasNicodic); | |
} | |
} | |
if (window.customElements) { | |
window.customElements.define('zenza-tag-item-menu', TagItemMenu); | |
} | |
class VideoInfoPanel extends Emitter { | |
constructor(params) { | |
super(); | |
this._videoHeaderPanel = new VideoHeaderPanel(params); | |
this._dialog = params.dialog; | |
this._config = Config; | |
this._dialog.on('canplay', this._onVideoCanPlay.bind(this)); | |
this._dialog.on('videoCount', this._onVideoCountUpdate.bind(this)); | |
if (params.node) { | |
this.appendTo(params.node); | |
} | |
} | |
_initializeDom() { | |
if (this._isInitialized) { | |
return; | |
} | |
this._isInitialized = true; | |
const $view = this._$view = uq.html(VideoInfoPanel.__tpl__); | |
const view = this._view = $view[0]; | |
const classList = this.classList = ClassList(view); | |
const $icon = this._$ownerIcon = $view.find('.ownerIcon'); | |
this._$ownerName = $view.find('.ownerName'); | |
this._$ownerPageLink = $view.find('.ownerPageLink'); | |
this._description = view.querySelector('.videoDescription'); | |
this._seriesList = view.querySelector('.seriesList'); | |
this._tagListView = new TagListView({ | |
parentNode: view.querySelector('.videoTagsContainer') | |
}); | |
this._relatedInfoMenu = new RelatedInfoMenu({ | |
parentNode: view.querySelector('.relatedInfoMenuContainer') | |
}); | |
this._videoMetaInfo = new VideoMetaInfo({ | |
parentNode: view.querySelector('.videoMetaInfoContainer') | |
}); | |
this._uaaContainer = view.querySelector('.uaaContainer'); | |
this._uaaView = new UaaView( | |
{parentNode: this._uaaContainer}); | |
this._ichibaContainer = view.querySelector('.ichibaContainer'); | |
this._ichibaItemView = new IchibaItemView( | |
{parentNode: this._ichibaContainer}); | |
view.addEventListener('mousemove', e => e.stopPropagation()); | |
view.addEventListener('command', this._onCommandEvent.bind(this)); | |
view.addEventListener('click', this._onClick.bind(this)); | |
view.addEventListener('wheel', e => e.stopPropagation(), {passive: true}); | |
$icon.on('load', () => $icon.raf.removeClass('is-loading')); | |
classList.add(Fullscreen.now() ? 'is-fullscreen' : 'is-notFullscreen'); | |
global.emitter.on('fullscreenStatusChange', isFull => { | |
classList.toggle('is-fullscreen', isFull); | |
classList.toggle('is-notFullscreen', !isFull); | |
}); | |
view.addEventListener('touchenter', () => classList.add('is-slideOpen'), {passive: true}); | |
global.emitter.on('hideHover', () => classList.remove('is-slideOpen')); | |
cssUtil.registerProps( | |
{name: '--base-description-color', syntax: '<color>', initialValue: '#888', inherits: true} | |
); | |
MylistPocketDetector.detect().then(pocket => { | |
this._pocket = pocket; | |
classList.add('is-pocketReady'); | |
}); | |
if (window.customElements) { | |
VideoItemObserver.observe({container: this._description}); | |
} | |
} | |
update(videoInfo) { | |
this._videoInfo = videoInfo; | |
this._videoHeaderPanel.update(videoInfo); | |
const owner = videoInfo.owner; | |
this._$ownerIcon.attr('src', owner.icon); | |
this._$ownerPageLink.attr('href', owner.url); | |
this._$ownerName.text(owner.name); | |
this._videoMetaInfo.update(videoInfo); | |
this._tagListView.update({ | |
tagList: videoInfo.tagList, | |
watchId: videoInfo.watchId, | |
videoId: videoInfo.videoId, | |
token: videoInfo.csrfToken, | |
watchAuthKey: videoInfo.watchAuthKey | |
}); | |
this._seriesList.textContent = ''; | |
if (videoInfo.series) { | |
const label = document.createElement('zenza-video-series-label'); | |
Object.assign(label.dataset, videoInfo.series); | |
this._seriesList.append(label); | |
} | |
this._updateVideoDescription(videoInfo.description, videoInfo.series); | |
const classList = this.classList; | |
classList.remove('userVideo', 'channelVideo', 'initializing'); | |
classList.toggle('is-community', this._videoInfo.isCommunityVideo); | |
classList.toggle('is-mymemory', this._videoInfo.isMymemory); | |
classList.add(videoInfo.isChannel ? 'channelVideo' : 'userVideo'); | |
this._ichibaItemView.clear(); | |
this._ichibaItemView.videoId = videoInfo.videoId; | |
this._uaaView.clear(); | |
this._uaaView.update(videoInfo); | |
this._relatedInfoMenu.update(videoInfo); | |
} | |
async _updateVideoDescription(html, series = null) { | |
this._description.textContent = ''; | |
this._zenTubeUrl = null; | |
if (series) { | |
if (series.prevVideo || series.nextVideo) { | |
html += `<br><br>「${textUtil.escapeHtml(series.title)}」 シリーズ前後の動画`; | |
} | |
if (series.prevVideo) { | |
html += `<br>前の動画 <a class="watch" href="https://www.nicovideo.jp/watch/${series.prevVideo.id}">${series.prevVideo.id}</a>`; | |
} | |
if (series.nextVideo) { | |
html += `<br>次の動画 <a class="watch" href="https://www.nicovideo.jp/watch/${series.nextVideo.id}">${series.nextVideo.id}</a>`; | |
} | |
} | |
const decorateWatchLink = watchLink => { | |
const videoId = watchLink.textContent.replace('watch/', ''); | |
if ( | |
!/^(sm|nm|so|)[0-9]+$/.test(videoId) || | |
!['www.nicovideo.jp'].includes(watchLink.hostname) || !watchLink.pathname.startsWith('/watch/')) { | |
return; | |
} | |
watchLink.classList.add('noHoverMenu'); | |
Object.assign(watchLink.dataset, {command: 'open', param: videoId}); | |
if (!window.customElements) { | |
const $watchLink = uq(watchLink); | |
const thumbnail = nicoUtil.getThumbnailUrlByVideoId(videoId); | |
if (thumbnail) { | |
const $img = uq('<img class="videoThumbnail">').attr('src', thumbnail); | |
$watchLink.append($img); | |
} | |
const buttons = uq(`<zenza-playlist-append | |
class="playlistAppend clickable-item" title="プレイリストで開く" | |
data-command="playlistAppend" data-param="${videoId}" | |
>▶</zenza-playlist-append><div | |
class="deflistAdd" title="とりあえずマイリスト" | |
data-command="deflistAdd" data-param="${videoId}" | |
>✚</div | |
><div class="pocket-info" title="動画情報" | |
data-command="pocket-info" data-param="${videoId}" | |
>?</div>`); | |
$watchLink.append(buttons); | |
} else { | |
const vitem = document.createElement('zenza-video-item'); | |
vitem.dataset.videoId = videoId; | |
watchLink.after(vitem); | |
watchLink.classList.remove('watch'); | |
} | |
}; | |
const seekTime = seek => { | |
const [min, sec] = (seek.dataset.seektime || '0:0').split(':'); | |
Object.assign(seek.dataset, {command: 'seek', type: 'number', param: min * 60 + sec * 1}); | |
}; | |
const mylistLink = link => { | |
link.classList.add('mylistLink'); | |
const mylistId = link.textContent.split('/')[1]; | |
const button = uq(`<zenza-mylist-link data-mylist-id="${mylistId}"> | |
${link.outerHTML} | |
<zenza-playlist-append | |
class="playlistSetMylist clickable-item" title="プレイリストで開く" | |
data-command="playlistSetMylist" data-param="${mylistId}" | |
>▶</zenza-playlist-append> | |
</zenza-mylist-link>`)[0]; | |
link.replaceWith(button); | |
}; | |
const youtube = link => { | |
const btn = uq(`<zentube-button | |
class="zenzaTubeButton" | |
title="ZenzaWatchで開く(実験中)" | |
accesskey="z" | |
data-command="setVideo;" | |
>▷Zen<span>Tube</span></zentube-button>`)[0]; | |
Object.assign(btn.dataset, { | |
command: 'setVideo', | |
param: link.href | |
}); | |
link.parentNode.insertBefore(btn, link); | |
}; | |
await sleep.promise(); | |
const $description = uq(`<zenza-video-description>${html}</zenza-video-description>`); | |
for (const a of $description.query('a')) { | |
a.classList.add('noHoverMenu'); | |
const href = a.href; | |
if (a.classList.contains('watch')) { | |
decorateWatchLink(a); | |
} else if (a.classList.contains('seekTime')) { | |
seekTime(a); | |
} else if (/^mylist\//.test(a.textContent)) { | |
mylistLink(a); | |
} else if (/^https?:\/\/((www\.|)youtube\.com\/watch|youtu\.be)/.test(href)) { | |
youtube(a); | |
this._zenTubeUrl = href; | |
} | |
} | |
for (const e of | |
$description.query('[style*="color: #000000;"],[style*="color: black;"]') | |
) { | |
e.dataset.originalCss = e.cssText; | |
e.style.color = '#FFF'; | |
} | |
for (const e of $description.query('span')) { | |
e.classList.add('videoDescription-font'); | |
} | |
this._description.append($description[0]); | |
} | |
async _onVideoCanPlay(watchId, videoInfo, options) { | |
if (!this._relatedVideoList) { | |
this._relatedVideoList = new RelatedVideoList({ | |
container: this._$view.find('.relatedVideoContainer')[0] | |
}); | |
this._relatedVideoList.on('command', this._onCommand.bind(this)); | |
} | |
if (this._config.props.autoZenTube && this._zenTubeUrl && !options.isAutoZenTubeDisabled) { | |
sleep(100).then(() => { | |
window.console.info('%cAuto ZenTube', this._zenTubeUrl); | |
this.emit('command', 'setVideo', this._zenTubeUrl); | |
}); | |
} | |
await sleep.idle(); | |
this._relatedVideoList.fetchRecommend(videoInfo.videoId, watchId, videoInfo); | |
} | |
_onVideoCountUpdate(...args) { | |
if (!this._videoHeaderPanel) { | |
return; | |
} | |
this._videoMetaInfo.updateVideoCount(...args); | |
this._videoHeaderPanel.updateVideoCount(...args); | |
} | |
_onClick(e) { | |
e.stopPropagation(); | |
if ( | |
(e.button !== 0 || e.metaKey || e.shiftKey || e.altKey || e.ctrlKey)) { | |
return true; | |
} | |
const target = e.target.closest('[data-command]'); | |
if (!target) { | |
global.emitter.emitAsync('hideHover'); // 手抜き | |
return; | |
} | |
let {command, param, type} = target.dataset; | |
if (param && (type === 'bool' || type === 'json')) { | |
param = JSON.parse(param); | |
} | |
e.preventDefault(); | |
domEvent.dispatchCommand(e.target, command, param); | |
} | |
_onCommand(command, param) { | |
switch (command) { | |
default: | |
domEvent.dispatchCommand(this._view, command, param); | |
break; | |
} | |
} | |
_onCommandEvent(e) { | |
const {command, param} = e.detail; | |
switch (command) { | |
case 'pocket-info': | |
this._pocket.external.info(param); | |
break; | |
case 'ownerVideo': | |
domEvent.dispatchCommand(this._view, 'playlistSetUploadedVideo', this._videoInfo.owner.id); | |
break; | |
default: | |
return; | |
} | |
e.stopPropagation(); | |
} | |
appendTo(node) { | |
this._initializeDom(); | |
this._$view.appendTo(node); | |
this._videoHeaderPanel.appendTo(node); | |
} | |
hide() { | |
this._videoHeaderPanel.hide(); | |
} | |
close() { | |
this._videoHeaderPanel.close(); | |
} | |
clear() { | |
this._videoHeaderPanel.clear(); | |
this.classList.add('initializing'); | |
this._$ownerIcon.raf.addClass('is-loading'); | |
this._description.textContent = ''; | |
} | |
selectTab(tabName) { | |
const $view = this._$view; | |
const $target = $view.find(`.tabs.${tabName}, .tabSelect.${tabName}`); | |
this._activeTabName = tabName; | |
$view.find('.activeTab').removeClass('activeTab'); | |
$target.addClass('activeTab'); | |
} | |
blinkTab(tabName) { | |
const $view = this._$view; | |
const $target = $view.find(`.tabs.${tabName}, .tabSelect.${tabName}`); | |
if (!$target.length) { | |
return; | |
} | |
$target.addClass('blink'); | |
window.setTimeout(() => $target.removeClass('blink'), 50); | |
} | |
appendTab(tabName, title, content) { | |
const $view = this._$view; | |
const $select = | |
uq('<div class="tabSelect"/>') | |
.addClass(tabName) | |
.attr('data-command', 'selectTab') | |
.attr('data-param', tabName) | |
.text(title); | |
const $body = uq('<div class="tabs"/>').addClass(tabName); | |
if (content) { | |
$body.append(content); | |
} | |
$view.find('.tabSelectContainer').append($select); | |
$view.append($body); | |
if (this._activeTabName === tabName) { | |
$select.addClass('activeTab'); | |
$body.addClass('activeTab'); | |
} | |
return $body; | |
} | |
} | |
css.addStyle(` | |
.zenzaWatchVideoInfoPanel .tabs:not(.activeTab) { | |
display: none; | |
pointer-events: none; | |
overflow: hidden; | |
} | |
.zenzaWatchVideoInfoPanel .tabs.activeTab { | |
margin-top: 32px; | |
box-sizing: border-box; | |
position: relative; | |
width: 100%; | |
height: calc(100% - 32px); | |
overflow-x: hidden; | |
overflow-y: visible; | |
overscroll-behavior: none; | |
text-align: left; | |
} | |
.zenzaWatchVideoInfoPanel .tabs.relatedVideoTab.activeTab { | |
overflow: hidden; | |
} | |
.zenzaWatchVideoInfoPanel .tabs:not(.activeTab) { | |
display: none !important; | |
pointer-events: none; | |
opacity: 0; | |
} | |
.zenzaWatchVideoInfoPanel .tabSelectContainer { | |
position: absolute; | |
display: flex; | |
height: 32px; | |
z-index: 100; | |
width: 100%; | |
white-space: nowrap; | |
user-select: none; | |
} | |
.zenzaWatchVideoInfoPanel .tabSelect { | |
flex: 1; | |
box-sizing: border-box; | |
display: inline-block; | |
height: 32px; | |
font-size: 12px; | |
letter-spacing: 0; | |
line-height: 32px; | |
color: #666; | |
background: #222; | |
cursor: pointer; | |
text-align: center; | |
transition: text-shadow 0.2s ease, color 0.2s ease; | |
} | |
.zenzaWatchVideoInfoPanel .tabSelect.activeTab { | |
font-size: 14px; | |
letter-spacing: 0.1em; | |
color: #ccc; | |
background: #333; | |
} | |
.zenzaWatchVideoInfoPanel .tabSelect.blink:not(.activeTab) { | |
color: #fff; | |
text-shadow: 0 0 4px #ff9; | |
transition: none; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel.is-notFullscreen .tabSelect.blink:not(.activeTab) { | |
color: #fff; | |
text-shadow: 0 0 4px #006; | |
transition: none; | |
} | |
.zenzaWatchVideoInfoPanel .tabSelect:not(.activeTab):hover { | |
background: #888; | |
} | |
.zenzaWatchVideoInfoPanel.initializing { | |
} | |
.zenzaWatchVideoInfoPanel>* { | |
transition: opacity 0.4s ease; | |
pointer-events: none; | |
} | |
.is-mouseMoving .zenzaWatchVideoInfoPanel>*, | |
.zenzaWatchVideoInfoPanel:hover>* { | |
pointer-events: auto; | |
} | |
.zenzaWatchVideoInfoPanel.initializing>* { | |
opacity: 0; | |
color: #333; | |
transition: none; | |
} | |
.zenzaWatchVideoInfoPanel { | |
position: absolute; | |
top: 0; | |
width: 320px; | |
height: 100%; | |
box-sizing: border-box; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 25000}; | |
background: #333; | |
color: #ccc; | |
overflow-x: hidden; | |
overflow-y: hidden; | |
transition: opacity 0.4s ease; | |
} | |
.zenzaWatchVideoInfoPanel .ownerPageLink { | |
display: block; | |
margin: 0 auto 8px; | |
width: 104px; | |
} | |
.zenzaWatchVideoInfoPanel .ownerIcon { | |
width: 96px; | |
height: 96px; | |
border: none; | |
border-radius: 4px; | |
transition: opacity 1s ease; | |
vertical-align: middle; | |
} | |
.zenzaWatchVideoInfoPanel .ownerIcon.is-loading { | |
opacity: 0; | |
} | |
.zenzaWatchVideoInfoPanel .ownerName { | |
font-size: 20px; | |
word-break: break-all; | |
} | |
.zenzaWatchVideoInfoPanel .videoOwnerInfoContainer { | |
padding: 16px; | |
display: table; | |
width: 100%; | |
} | |
.zenzaWatchVideoInfoPanel .videoOwnerInfoContainer>*{ | |
display: block; | |
vertical-align: middle; | |
text-align: center; | |
} | |
.zenzaWatchVideoInfoPanel .videoDescription { | |
padding: 8px 8px 8px; | |
margin: 4px 0px; | |
word-break: break-all; | |
line-height: 1.5; | |
} | |
.zenzaWatchVideoInfoPanel .videoDescription a { | |
display: inline-block; | |
font-weight: bold; | |
text-decoration: none; | |
color: #ff9; | |
padding: 2px; | |
} | |
.zenzaWatchVideoInfoPanel .videoDescription a:visited { | |
color: #ffd; | |
} | |
.zenzaWatchVideoInfoPanel .videoDescription .watch { | |
display: block; | |
position: relative; | |
line-height: 60px; | |
box-sizing: border-box; | |
padding: 4px 16px;; | |
min-height: 60px; | |
width: 272px; | |
margin: 8px 10px; | |
background: #444; | |
border-radius: 4px; | |
} | |
.zenzaWatchVideoInfoPanel .videoDescription .watch:hover { | |
background: #446; | |
} | |
.videoDescription-font { | |
text-shadow: 1px 1px var(--base-description-color, #888); | |
} | |
.zenzaWatchVideoInfoPanel .videoDescription .mylistLink { | |
white-space: nowrap; | |
display: inline-block; | |
} | |
.zenzaWatchVideoInfoPanel:not(.is-pocketReady) .pocket-info { | |
display: none !important; | |
} | |
.pocket-info { | |
font-family: Menlo; | |
} | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistAppend, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .deflistAdd, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistSetMylist, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .pocket-info, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistSetUploadedVideo { | |
display: inline-block; | |
font-size: 16px; | |
line-height: 20px; | |
width: 24px; | |
height: 24px; | |
background: #666; | |
color: #ccc !important; | |
background: #666; | |
text-decoration: none; | |
border: 1px outset; | |
cursor: pointer; | |
text-align: center; | |
user-select: none; | |
margin-left: 8px; | |
} | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistAppend, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .pocket-info, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .deflistAdd { | |
display: none; | |
} | |
.zenzaWatchVideoInfoPanel .videoInfoTab .owner:hover .playlistAppend, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .watch:hover .playlistAppend, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .watch:hover .pocket-info, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .watch:hover .deflistAdd { | |
display: inline-block; | |
} | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistAppend { | |
position: absolute; | |
bottom: 4px; | |
left: 16px; | |
} | |
.zenzaWatchVideoInfoPanel .videoInfoTab .pocket-info { | |
position: absolute; | |
bottom: 4px; | |
left: 48px; | |
} | |
.zenzaWatchVideoInfoPanel .videoInfoTab .deflistAdd { | |
position: absolute; | |
bottom: 4px; | |
left: 80px; | |
} | |
.zenzaWatchVideoInfoPanel .videoInfoTab .pocket-info:hover, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistAppend:hover, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .deflistAdd:hover, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistSetMylist:hover, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistSetUploadedVideo:hover { | |
transform: scale(1.5); | |
} | |
.zenzaWatchVideoInfoPanel .videoInfoTab .pocket-info:active, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistAppend:active, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .deflistAdd:active, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistSetMylist:active, | |
.zenzaWatchVideoInfoPanel .videoInfoTab .playlistSetUploadedVideo:active { | |
transform: scale(1.2); | |
border: 1px inset; | |
} | |
.zenzaWatchVideoInfoPanel .videoDescription .watch .videoThumbnail { | |
position: absolute; | |
right: 16px; | |
height: 60px; | |
pointer-events: none; | |
} | |
.zenzaWatchVideoInfoPanel .videoDescription:hover .watch .videoThumbnail { | |
filter: none; | |
} | |
.zenzaWatchVideoInfoPanel .publicStatus, | |
.zenzaWatchVideoInfoPanel .videoTagsContainer { | |
display: none; | |
} | |
.zenzaWatchVideoInfoPanel .publicStatus { | |
display: none; | |
position: relative; | |
margin: 8px 0; | |
padding: 8px; | |
line-height: 150%; | |
text-align; center; | |
color: #333; | |
} | |
.zenzaWatchVideoInfoPanel .videoMetaInfoContainer { | |
display: inline-block; | |
padding: 0 8px; | |
} | |
.zenzaScreenMode_normal .is-backComment .zenzaWatchVideoInfoPanel, | |
.zenzaScreenMode_big .is-backComment .zenzaWatchVideoInfoPanel { | |
opacity: 0.7; | |
} | |
.zenzaWatchVideoInfoPanel .relatedVideoTab .relatedVideoContainer { | |
box-sizing: border-box; | |
position: relative; | |
width: 100%; | |
height: 100%; | |
margin: 0; | |
user-select: none; | |
} | |
.zenzaWatchVideoInfoPanel .videoListFrame, | |
.zenzaWatchVideoInfoPanel .commentListFrame { | |
width: 100%; | |
height: 100%; | |
box-sizing: border-box; | |
border: 0; | |
background: #333; | |
} | |
.zenzaWatchVideoInfoPanel .nowLoading { | |
display: none; | |
opacity: 0; | |
pointer-events: none; | |
} | |
.zenzaWatchVideoInfoPanel.initializing .nowLoading { | |
display: block !important; | |
opacity: 1 !important; | |
color: #888; | |
} | |
.zenzaWatchVideoInfoPanel .nowLoading { | |
position: absolute; | |
top: 0; left: 0; | |
width: 100%; height: 100%; | |
} | |
.zenzaWatchVideoInfoPanel .kurukuru { | |
position: absolute; | |
display: inline-block; | |
font-size: 96px; | |
left: 50%; | |
top: 50%; | |
transform: translate(-50%, -50%); | |
} | |
@keyframes loadingRolling { | |
0% { transform: rotate(0deg); } | |
100% { transform: rotate(1800deg); } | |
} | |
.zenzaWatchVideoInfoPanel.initializing .kurukuruInner { | |
display: inline-block; | |
pointer-events: none; | |
text-align: center; | |
text-shadow: 0 0 4px #888; | |
animation-name: loadingRolling; | |
animation-iteration-count: infinite; | |
animation-duration: 4s; | |
} | |
.zenzaWatchVideoInfoPanel .nowLoading .loadingMessage { | |
position: absolute; | |
display: inline-block; | |
font-family: Impact; | |
font-size: 32px; | |
text-align: center; | |
top: calc(50% + 48px); | |
left: 0; | |
width: 100%; | |
} | |
${CONSTANT.SCROLLBAR_CSS} | |
.zenzaWatchVideoInfoPanel .zenzaWatchVideoInfoPanelInner { | |
display: flex; | |
flex-direction: column; | |
height: 100%; | |
} | |
.zenzaWatchVideoInfoPanelContent { | |
flex: 1; | |
} | |
.zenzaTubeButton { | |
display: inline-block; | |
padding: 4px 8px; | |
cursor: pointer; | |
background: #666; | |
color: #ccc; | |
border-radius: 4px; | |
border: 1px outset; | |
margin: 0 8px; | |
} | |
.zenzaTubeButton:hover { | |
box-shadow: 0 0 8px #fff, 0 0 4px #ccc; | |
} | |
.zenzaTubeButton span { | |
pointer-events: none; | |
display: inline-block; | |
background: #ccc; | |
color: #333; | |
border-radius: 4px; | |
} | |
.zenzaTubeButton:hover span { | |
background: #f33; | |
color: #ccc; | |
} | |
.zenzaTubeButton:active { | |
box-shadow: 0 0 2px #ccc, 0 0 4px #000 inset; | |
border: 1px inset; | |
} | |
.zenzaWatchVideoInfoPanel .relatedInfoMenuContainer { | |
text-align: left; | |
} | |
.zenzaWatchVideoInfoPanel .seriesList { | |
padding: 0 8px; | |
} | |
zenza-video-item, | |
zenza-video-series-label, | |
zenza-vieo-description, | |
.UaaView, | |
.ZenzaIchibaItemView { | |
content-visibility: auto; | |
} | |
`, {className: 'videoInfoPanel'}); | |
css.addStyle(` | |
.is-open .zenzaWatchVideoInfoPanel>* { | |
display: none; | |
pointer-events: none; | |
} | |
.zenzaWatchVideoInfoPanel:hover>* { | |
display: inherit; | |
pointer-events: auto; | |
} | |
.zenzaWatchVideoInfoPanel:hover .tabSelectContainer { | |
display: flex; | |
} | |
.zenzaWatchVideoInfoPanel { | |
top: 20%; | |
right: calc(32px - 320px); | |
left: auto; | |
width: 320px; | |
height: 60%; | |
border: 1px solid transparent; | |
background: none; | |
opacity: 0; | |
box-shadow: none; | |
transition: opacity 0.4s ease, transform 0.4s ease 1s; | |
will-change: opacity, transform; | |
} | |
.is-mouseMoving .zenzaWatchVideoInfoPanel { | |
border: 1px solid #888; | |
opacity: 0.5; | |
} | |
.zenzaWatchVideoInfoPanel.is-slideOpen, | |
.zenzaWatchVideoInfoPanel:hover { | |
background: #333; | |
box-shadow: 4px 4px 4px #000; | |
border: none; | |
opacity: 0.9; | |
transform: translate3d(-288px, 0, 0); | |
transition: opacity 0.4s ease, transform 0.4s ease 1s; | |
} | |
`, {className: 'screenMode for-full videoInfoPanel'}); | |
css.addStyle(` | |
.zenzaScreenMode_small .zenzaWatchVideoInfoPanel { | |
display: none; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .tabSelectContainer { | |
width: calc(100% - 16px); | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .tabSelect { | |
background: #ccc; | |
color: #888; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .tabSelect.activeTab { | |
background: #ddd; | |
color: black; | |
border: none; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel { | |
top: 230px; | |
left: 0; | |
width: ${CONSTANT.SIDE_PLAYER_WIDTH}px; | |
height: calc(100vh - 296px); | |
bottom: 48px; | |
padding: 8px; | |
box-shadow: none; | |
background: #f0f0f0; | |
color: #000; | |
border: 1px solid #333; | |
margin: 4px 2px; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .publicStatus { | |
display: block; | |
text-align: center; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .videoDescription a { | |
color: #006699; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .videoDescription a:visited { | |
color: #666666; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .videoTagsContainer { | |
display: block; | |
bottom: 48px; | |
width: 364px; | |
margin: 0 auto; | |
padding: 8px; | |
background: #ccc; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .videoDescription .watch { | |
background: #ddd; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .videoDescription .watch:hover { | |
background: #ddf; | |
} | |
.zenzaScreenMode_sideView .videoInfoTab::-webkit-scrollbar { | |
background: #f0f0f0; | |
} | |
.zenzaScreenMode_sideView .videoInfoTab::-webkit-scrollbar-thumb { | |
border-radius: 0; | |
background: #ccc; | |
} | |
`, {className: 'screenMode for-popup videoInfoPanel'}); | |
uq.ready().then(() => { | |
if (document.body.classList.contains('MatrixRanking-body')) { | |
css.addStyle(` | |
body.zenzaScreenMode_sideView.MatrixRanking-body .RankingRowRank { | |
line-height: 48px; | |
height: 48px; | |
pointer-events: none; | |
user-select: none; | |
} | |
body.zenzaScreenMode_sideView.MatrixRanking-body .RankingRowRank { | |
position: sticky; | |
left: calc(var(--sideView-left-margin) - 8px); | |
z-index: 100; | |
transform: none; | |
padding-right: 16px; | |
width: 64px; | |
overflow: visible; | |
text-align: right; | |
mix-blend-mode: difference; | |
text-shadow: | |
1px 1px 0 #fff, | |
1px -1px 0 #fff, | |
-1px 1px 0 #fff, | |
-1px -1px 0 #fff; | |
} | |
body.zenzaScreenMode_sideView.MatrixRanking-body .BaseLayout-block { | |
width: ${1024 + 64 * 2}px; | |
} | |
.RankingMainContainer-decorateChunk+.RankingMainContainer-decorateChunk, | |
.RankingMainContainer-decorateChunk>*+* { | |
margin-top: 0; | |
} | |
body.zenzaScreenMode_sideView .RankingMainContainer { | |
width: ${1024}px; | |
} | |
body.zenzaScreenMode_sideView.MatrixRanking-body .RankingMatrixVideosRow { | |
width: ${1024 + 64}px; | |
margin-left: ${-64}px; | |
} | |
.RankingGenreListContainer-categoryHelp { | |
position: static; | |
} | |
.RankingMatrixNicoadsRow>*+*, | |
.RankingMatrixVideosRow>:nth-child(n+3) { | |
margin-left: 13px; | |
} | |
.RankingBaseItem { | |
width: 160px; | |
height: 196px; | |
} | |
body.zenzaScreenMode_sideView .RankingBaseItem .Card-link { | |
grid-template-rows: 90px auto; | |
} | |
.VideoItem.RankingBaseItem .VideoThumbnail { | |
border-radius: 3px 3px 0 0; | |
} | |
[data-nicoad-grade] .Thumbnail.VideoThumbnail .Thumbnail-image { | |
margin: 3px; | |
background-size: calc(100% + 6px); | |
} | |
[data-nicoad-grade] .Thumbnail.VideoThumbnail:after { | |
width: 40px; | |
height: 40px; | |
background-size: 80px 80px; | |
} | |
.Thumbnail.VideoThumbnail .VideoLength { | |
bottom: 3px; | |
right: 3px; | |
} | |
.VideoThumbnailComment { | |
transform: scale(0.8333); | |
} | |
.RankingBaseItem-meta { | |
position: static; | |
padding: 0 4px 8px; | |
} | |
.VideoItem.RankingBaseItem .VideoItem-metaCount>.VideoMetaCount { | |
white-space: nowrap; | |
} | |
.RankingMainContainer .ToTopButton { | |
transform: translateX(calc(100vw / 2 - 100% - 36px)); | |
user-select: none; | |
} | |
`, {className: 'screenMode for-sideView MatrixRanking', disabled: true}); | |
} | |
}); | |
css.addStyle(` | |
.is-open .zenzaWatchVideoInfoPanel { | |
display: none; | |
left: calc(100%); | |
top: 0; | |
} | |
@media screen { | |
@media (min-width: 992px) { | |
.zenzaScreenMode_normal .zenzaWatchVideoInfoPanel { | |
display: inherit; | |
} | |
} | |
@media (min-width: 1216px) { | |
.zenzaScreenMode_big .zenzaWatchVideoInfoPanel { | |
display: inherit; | |
} | |
} | |
/* 縦長モニター */ | |
@media | |
(max-width: 991px) and (min-height: 700px) | |
{ | |
.zenzaScreenMode_normal .zenzaWatchVideoInfoPanel { | |
display: inherit; | |
top: 100%; | |
left: 0; | |
width: 100%; | |
height: ${CONSTANT.BOTTOM_PANEL_HEIGHT}px; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 20000}; | |
} | |
.zenzaScreenMode_normal .ZenzaIchibaItemView { | |
margin: 8px 8px 96px; | |
} | |
.zenzaScreenMode_normal .zenzaWatchVideoInfoPanel .videoOwnerInfoContainer { | |
display: table; | |
} | |
.zenzaScreenMode_normal .zenzaWatchVideoInfoPanel .videoOwnerInfoContainer>* { | |
display: table-cell; | |
text-align: left; | |
} | |
.zenzaScreenMode_normal .zenzaWatchVideoHeaderPanel { | |
width: 100% !important; | |
} | |
} | |
@media | |
(max-width: 1215px) and (min-height: 700px) { | |
.zenzaScreenMode_big .zenzaWatchVideoInfoPanel { | |
display: inherit; | |
top: 100%; | |
left: 0; | |
width: 100%; | |
height: ${CONSTANT.BOTTOM_PANEL_HEIGHT}px; | |
z-index: ${CONSTANT.BASE_Z_INDEX + 20000}; | |
} | |
.zenzaScreenMode_big .ZenzaIchibaItemView { | |
margin: 8px 8px 96px; | |
} | |
.zenzaScreenMode_big .zenzaWatchVideoInfoPanel .videoOwnerInfoContainer { | |
display: table; | |
} | |
.zenzaScreenMode_big .zenzaWatchVideoInfoPanel .videoOwnerInfoContainer>* { | |
display: table-cell; | |
text-align: left; | |
} | |
.zenzaScreenMode_big .zenzaWatchVideoHeaderPanel { | |
width: 100% !important; | |
} | |
} | |
} | |
`, {className: 'screenMode for-dialog videoInfoPanel'}); | |
css.addStyle(` | |
.zenzaWatchVideoInfoPanel .comment { | |
padding-left: 0; | |
} | |
`, {className: 'domain slack-com', disabled: true}); | |
VideoInfoPanel.__tpl__ = (` | |
<div class="zenzaWatchVideoInfoPanel show initializing"> | |
<div class="nowLoading"> | |
<div class="kurukuru"><span class="kurukuruInner">☯</span></div> | |
<div class="loadingMessage">Loading...</div> | |
</div> | |
<div class="tabSelectContainer"><div class="tabSelect videoInfoTab activeTab" data-command="selectTab" data-param="videoInfoTab">動画情報</div><div class="tabSelect relatedVideoTab" data-command="selectTab" data-param="relatedVideoTab">関連動画</div></div> | |
<div class="tabs videoInfoTab activeTab"> | |
<div class="zenzaWatchVideoInfoPanelInner"> | |
<div class="zenzaWatchVideoInfoPanelContent"> | |
<div class="videoOwnerInfoContainer"> | |
<a class="ownerPageLink" rel="noopener" target="_blank"> | |
<img class="ownerIcon loading"/> | |
</a> | |
<span class="owner"> | |
<span class="ownerName"></span> | |
<zenza-playlist-append class="playlistSetUploadedVideo userVideo" | |
data-command="ownerVideo" | |
title="投稿動画一覧をプレイリストで開く">▶</zenza-playlist-append> | |
</span> | |
</div> | |
<div class="publicStatus"> | |
<div class="videoMetaInfoContainer"></div> | |
<div class="relatedInfoMenuContainer"></div> | |
</div> | |
<div class="seriesList"></div> | |
<div class="videoDescription"></div> | |
</div> | |
<div class="zenzaWatchVideoInfoPanelFoot"> | |
<div class="uaaContainer"></div> | |
<div class="ichibaContainer"></div> | |
<div class="videoTagsContainer sideTab"></div> | |
</div> | |
</div> | |
</div> | |
<div class="tabs relatedVideoTab"> | |
<div class="relatedVideoContainer"></div> | |
</div> | |
</div> | |
`).trim(); | |
class VideoHeaderPanel extends Emitter { | |
constructor(params) { | |
super(); | |
} | |
_initializeDom() { | |
if (this._isInitialized) { | |
return; | |
} | |
this._isInitialized = true; | |
cssUtil.addStyle(VideoHeaderPanel.__css__); | |
const $view = this._$view = uq.html(VideoHeaderPanel.__tpl__); | |
const view = $view[0]; | |
const classList = this.classList = ClassList(view); | |
this._videoTitle = $view.find('.videoTitle')[0]; | |
this._searchForm = new VideoSearchForm({ | |
parentNode: view | |
}); | |
$view.on('wheel', e => e.stopPropagation(), {passive: true}); | |
this._seriesCover = view.querySelector('.series-thumbnail'); | |
this._tagListView = new TagListView({ | |
parentNode: view.querySelector('.videoTagsContainer') | |
}); | |
this._relatedInfoMenu = new RelatedInfoMenu({ | |
parentNode: view.querySelector('.relatedInfoMenuContainer'), | |
isHeader: true | |
}); | |
this._relatedInfoMenu.on('open', () => classList.add('is-relatedMenuOpen')); | |
this._relatedInfoMenu.on('close', () => classList.remove('is-relatedMenuOpen')); | |
this._videoMetaInfo = new VideoMetaInfo({ | |
parentNode: view.querySelector('.videoMetaInfoContainer'), | |
}); | |
classList.add(Fullscreen.now() ? 'is-fullscreen' : 'is-notFullscreen'); | |
global.emitter.on('fullScreenStatusChange', isFull => { | |
classList.toggle('is-fullscreen', isFull); | |
classList.toggle('is-notFullscreen', !isFull); | |
}); | |
window.addEventListener('resize', _.debounce(this._onResize.bind(this), 500)); | |
} | |
update(videoInfo) { | |
this._videoInfo = videoInfo; | |
this._videoTitle.title = this._videoTitle.textContent = videoInfo.title; | |
const watchId = videoInfo.watchId; | |
this._videoMetaInfo.update(videoInfo); | |
this._tagListView.update({ | |
tagList: videoInfo.tagList, | |
watchId, | |
videoId: videoInfo.videoId, | |
token: videoInfo.csrfToken, | |
watchAuthKey: videoInfo.watchAuthKey | |
}); | |
this._relatedInfoMenu.update(videoInfo); | |
const classList = this.classList; | |
classList.remove('userVideo', 'channelVideo', 'initializing'); | |
classList.toggle('is-community', this._videoInfo.isCommunityVideo); | |
classList.toggle('is-mymemory', this._videoInfo.isMymemory); | |
classList.toggle('has-Parent', this._videoInfo.hasParentVideo); | |
classList.add(videoInfo.isChannel ? 'channelVideo' : 'userVideo'); | |
this._$view.raf.css('display', ''); | |
if (videoInfo.series && videoInfo.series.thumbnailUrl) { | |
this._seriesCover.style.backgroundImage = `url("${videoInfo.series.thumbnailUrl}")`; | |
} else { | |
this._seriesCover.removeAttribute('style'); | |
} | |
window.setTimeout(() => this._onResize(), 1000); | |
} | |
updateVideoCount(...args) { | |
this._videoMetaInfo.updateVideoCount(...args); | |
} | |
_onResize() { | |
const view = this._$view[0]; | |
const rect = view.getBoundingClientRect(); | |
const isOnscreen = this.classList.contains('is-onscreen'); | |
const height = rect.bottom - rect.top; | |
const top = isOnscreen ? (rect.top - height) : rect.top; | |
this.classList.toggle('is-onscreen', top < -32); | |
} | |
appendTo(node) { | |
this._initializeDom(); | |
this._$view.appendTo(node); | |
} | |
hide() { | |
if (!this._$view) { | |
return; | |
} | |
this.classList.remove('show'); | |
} | |
close() { | |
} | |
clear() { | |
if (!this._$view) { | |
return; | |
} | |
this.classList.add('initializing'); | |
this._videoTitle.textContent = ''; | |
} | |
getPublicStatusDom() { | |
return this._$view.find('.publicStatus').html(); | |
} | |
} | |
css.addStyle(` | |
.zenzaScreenMode_small .zenzaWatchVideoHeaderPanel { | |
display: none; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoHeaderPanel { | |
top: 0; | |
left: 400px; | |
width: calc(100vw - 400px); | |
bottom: auto; | |
background: #272727; | |
opacity: 0.9; | |
height: 40px; | |
} | |
/* ヘッダ追従 */ | |
body.zenzaScreenMode_sideView:not(.nofix) .zenzaWatchVideoHeaderPanel { | |
top: 0; | |
} | |
/* ヘッダ固定 */ | |
.zenzaScreenMode_sideView .zenzaWatchVideoHeaderPanel .videoTitleContainer { | |
margin: 0; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoHeaderPanel .publicStatus, | |
.zenzaScreenMode_sideView .zenzaWatchVideoHeaderPanel .videoTagsContainer { | |
display: none; | |
} | |
@media screen and (min-width: 1432px) | |
{ | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .tabSelectContainer { | |
width: calc(100% - 16px); | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel { | |
top: calc((100vw - 1024px) * 9 / 16 + 4px); | |
width: calc(100vw - 1024px); | |
height: calc(100vh - (100vw - 1024px) * 9 / 16 - 70px); | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoInfoPanel .videoTagsContainer { | |
width: calc(100vw - 1024px - 26px); | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoHeaderPanel { | |
width: calc(100vw - (100vw - 1024px)); | |
left: calc(100vw - 1024px); | |
} | |
} | |
`, {className: 'screenMode for-popup videoHeaderPanel', disabled: true}); | |
css.addStyle(` | |
body .is-open .zenzaWatchVideoHeaderPanel { | |
width: calc(100% + ${CONSTANT.RIGHT_PANEL_WIDTH}px); | |
} | |
.zenzaWatchVideoHeaderPanel.is-onscreen { | |
top: 0px; | |
bottom: auto; | |
background: rgba(0, 0, 0, 0.5); | |
opacity: 0; | |
box-shadow: none; | |
} | |
.is-loading .zenzaWatchVideoHeaderPanel.is-onscreen { | |
opacity: 0.6; | |
transition: 0.4s opacity; | |
} | |
.zenzaWatchVideoHeaderPanel.is-onscreen:hover { | |
opacity: 1; | |
transition: 0.5s opacity; | |
} | |
.zenzaWatchVideoHeaderPanel.is-onscreen .videoTagsContainer { | |
display: none; | |
width: calc(100% - 240px); | |
} | |
.zenzaWatchVideoHeaderPanel.is-onscreen:hover .videoTagsContainer { | |
display: block; | |
} | |
.zenzaWatchVideoHeaderPanel.is-onscreen .videoTitleContainer { | |
width: calc(100% - 180px); | |
} | |
.zenzaWatchVideoInfoPanelFoot { | |
background: #222; | |
} | |
`, {className: 'screenMode for-dialog videoHeaderPanel', disabled: true}); | |
css.addStyle(` | |
.is-open .zenzaWatchVideoHeaderPanel { | |
position: absolute; /* fixedだとFirefoxのバグでおかしくなる */ | |
top: 0px; | |
bottom: auto; | |
background: rgba(0, 0, 0, 0.5); | |
opacity: 0; | |
box-shadow: none; | |
} | |
.is-loading .zenzaWatchVideoHeaderPanel, | |
.is-mouseMoving .zenzaWatchVideoHeaderPanel { | |
opacity: 0.6; | |
transition: 0.4s opacity; | |
} | |
.is-open .showVideoHeaderPanel .zenzaWatchVideoHeaderPanel, | |
.is-open .zenzaWatchVideoHeaderPanel:hover { | |
opacity: 1; | |
transition: 0.5s opacity; | |
} | |
.is-open .videoTagsContainer { | |
display: none; | |
width: calc(100% - 240px); | |
} | |
.is-open .zenzaWatchVideoHeaderPanel:hover .videoTagsContainer { | |
display: block; | |
} | |
.is-open .zenzaWatchVideoHeaderPanel .videoTitleContainer { | |
width: calc(100% - 180px); | |
} | |
`, {className: 'screenMode for-full videoHeaderPanel', disabled: true}); | |
VideoHeaderPanel.__css__ = (` | |
.zenzaWatchVideoHeaderPanel { | |
position: absolute; | |
width: calc(100%); | |
z-index: ${CONSTANT.BASE_Z_INDEX + 30000}; | |
box-sizing: border-box; | |
padding: 8px 8px 0; | |
bottom: calc(100% + 8px); | |
left: 0; | |
background: #333; | |
color: #ccc; | |
text-align: left; | |
box-shadow: 4px 4px 4px #000; | |
transition: opacity 0.4s ease; | |
will-change: transform; | |
} | |
.zenzaWatchVideoHeaderPanel.is-onscreen { | |
width: 100% !important; | |
} | |
.zenzaScreenMode_sideView .zenzaWatchVideoHeaderPanel, | |
.zenzaWatchVideoHeaderPanel.is-fullscreen { | |
z-index: ${CONSTANT.BASE_Z_INDEX + 20000}; | |
} | |
.zenzaWatchVideoHeaderPanel { | |
pointer-events: none; | |
} | |
.is-mouseMoving .zenzaWatchVideoHeaderPanel, | |
.zenzaWatchVideoHeaderPanel:hover { | |
pointer-events: auto; | |
} | |
.zenzaWatchVideoHeaderPanel.initializing { | |
display: none; | |
} | |
.zenzaWatchVideoHeaderPanel.initializing>*{ | |
opacity: 0; | |
} | |
.zenzaWatchVideoHeaderPanel .videoTitleContainer { | |
margin: 8px; | |
} | |
.zenzaWatchVideoHeaderPanel .publicStatus { | |
position: relative; | |
color: #ccc; | |
} | |
.zenzaWatchVideoHeaderPanel .videoTitle { | |
font-size: 24px; | |
color: #fff; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
overflow: hidden; | |
display: block; | |
padding: 2px 0; | |
} | |
.zenzaWatchVideoHeaderPanel .videoTitle::before { | |
display: none; | |
position: absolute; | |
font-size: 12px; | |
top: 0; | |
left: 0; | |
background: #333; | |
border: 1px solid #888; | |
padding: 2px 4px; | |
pointer-events: none; | |
} | |
.zenzaWatchVideoHeaderPanel.is-mymemory:not(:hover) .videoTitle::before { | |
content: 'マイメモリー'; | |
display: inline-block; | |
} | |
.zenzaWatchVideoHeaderPanel.is-community:not(:hover) .videoTitle::before { | |
content: 'コミュニティ動画'; | |
display: inline-block; | |
} | |
.videoMetaInfoContainer { | |
display: inline-block; | |
} | |
.zenzaScreenMode_normal .is-backComment .zenzaWatchVideoHeaderPanel, | |
.zenzaScreenMode_big .is-backComment .zenzaWatchVideoHeaderPanel { | |
opacity: 0.7; | |
} | |
.zenzaWatchVideoHeaderPanel .relatedInfoMenuContainer { | |
display: inline-block; | |
position: absolute; | |
top: 0; | |
margin: 0 16px; | |
z-index: 1000; | |
} | |
.zenzaWatchVideoHeaderPanel:focus-within, | |
.zenzaWatchVideoHeaderPanel.is-relatedMenuOpen { | |
z-index: ${CONSTANT.BASE_Z_INDEX + 50000}; | |
} | |
.zenzaWatchVideoHeaderPanel .series-thumbnail-cover { | |
position: absolute; | |
top: 0px; | |
right: 0px; | |
width: 50%; | |
height: 100%; | |
display: inline-block; | |
overflow: hidden; | |
contain: strict; | |
pointer-events: none; | |
user-select: none; | |
} | |
.zenzaWatchVideoHeaderPanel .series-thumbnail[style] { | |
width: 100%; | |
height: 100%; | |
box-sizing: border-box; | |
/*filter: sepia(50%) blur(4px);*/ | |
background-size: cover; | |
background-position: center center; | |
background-repeat: no-repeat; | |
will-change: transform; | |
-webkit-mask-image: | |
linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.3) 100%); | |
mask-image: | |
linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.3) 100%); | |
} | |
`); | |
VideoHeaderPanel.__tpl__ = (` | |
<div class="zenzaWatchVideoHeaderPanel show initializing" style="display: none;"> | |
<h2 class="videoTitleContainer"> | |
<span class="videoTitle"></span> | |
</h2> | |
<p class="publicStatus"> | |
<span class="videoMetaInfoContainer"></span> | |
<span class="relatedInfoMenuContainer"></span> | |
</p> | |
<div class="videoTagsContainer videoHeader"> | |
</div> | |
<div class="series-thumbnail-cover"><div class="series-thumbnail"></div></div> | |
</div> | |
`).trim(); | |
class VideoSearchForm extends Emitter { | |
constructor(...args) { | |
super(); | |
this._config = Config.namespace('videoSearch'); | |
this._initDom(...args); | |
} | |
_initDom({parentNode}) { | |
let tpl = document.getElementById('zenzaVideoSearchPanelTemplate'); | |
if (!tpl) { | |
cssUtil.addStyle(VideoSearchForm.__css__); | |
tpl = document.createElement('template'); | |
tpl.innerHTML = VideoSearchForm.__tpl__; | |
tpl.id = 'zenzaVideoSearchPanelTemplate'; | |
} | |
const view = document.importNode(tpl.content, true); | |
this._view = view.querySelector('*'); | |
this._form = view.querySelector('form'); | |
this._word = view.querySelector('.searchWordInput'); | |
this._sort = view.querySelector('.searchSortSelect'); | |
this._mode = view.querySelector('.searchMode') || 'tag'; | |
this._form.addEventListener('submit', this._onSubmit.bind(this)); | |
const config = this._config; | |
const form = this._form; | |
form['ownerOnly'].checked = config.props.ownerOnly; | |
const confMode = config.props.mode; | |
if (typeof confMode === 'string' && ['tag', 'keyword'].includes(confMode)) { | |
form['mode'].value = confMode; | |
} else if (typeof confMode === 'boolean') { | |
form['mode'].value = confMode ? 'tag' : 'keyword'; | |
} else { | |
form['mode'].value = 'tag'; | |
} | |
form['word'].value = config.props.word; | |
form['sort'].value = config.props.sort; | |
this._view.addEventListener('click', this._onClick.bind(this)); | |
view.addEventListener('paste', e => e.stopPropagation()); | |
const submit = _.debounce(this.submit.bind(this), 500); | |
Array.from(view.querySelectorAll('input, select')).forEach(item => { | |
if (item.type === 'checkbox') { | |
item.addEventListener('change', () => { | |
this._word.focus(); | |
config.props[item.name] = item.checked; | |
submit(); | |
}); | |
} else if (item.type === 'radio') { | |
item.addEventListener('change', () => { | |
this._word.focus(); | |
config.props[item.name] = this._form[item.name].value; | |
submit(); | |
}); | |
} else { | |
item.addEventListener('change', () => { | |
config.props[item.name] = item.value; | |
if (item.tagName === 'SELECT') { | |
submit(); | |
} | |
}); | |
} | |
}); | |
global.emitter.on('searchVideo', ({word}) => { | |
form['word'].value = word; | |
}); | |
if (parentNode) { | |
parentNode.appendChild(view); | |
} | |
global.debug.searchForm = this; | |
} | |
_onClick(e) { | |
e.stopPropagation(); | |
const tagName = (e.target.tagName || '').toLowerCase(); | |
const target = e.target.closest('.command'); | |
if (!['input', 'select'].includes(tagName)) { | |
this._word.focus(); | |
} | |
if (!target) { | |
return; | |
} | |
const command = target.dataset.command; | |
if (!command) { | |
return; | |
} | |
e.preventDefault(); | |
const type = target.getAttribute('data-type') || 'string'; | |
let param = target.getAttribute('data-param'); | |
if (type !== 'string') { param = JSON.parse(param); } | |
switch (command) { | |
case 'clear': | |
this._word.value = ''; | |
break; | |
default: | |
domEvent.dispatchCommand(e.target, command, param); | |
} | |
} | |
_onSubmit(e) { | |
this.submit(); | |
e.stopPropagation(); | |
} | |
submit() { | |
const word = this.word; | |
if (!word) { | |
return; | |
} | |
domEvent.dispatchCommand(this._view, 'playlistSetSearchVideo', { | |
word, | |
option: { | |
searchType: this.searchType, | |
sort: this.sort, | |
order: this.order, | |
owner: this.isOwnerOnly, | |
playlistSort: this.isPlaylistSort | |
} | |
}); | |
} | |
_hasFocus() { | |
return !!document.activeElement.closest('#zenzaVideoSearchPanel'); | |
} | |
_updateFocus() { | |
} | |
get word() { | |
return (this._word.value || '').trim(); | |
} | |
get searchType() { | |
return this._form.mode.value; | |
} | |
get sort() { | |
const sortTmp = (this._sort.value || '').split(','); | |
const playlistSort = sortTmp[0] === 'playlist'; | |
return playlistSort ? 'f' : sortTmp[0]; | |
} | |
get order() { | |
const sortTmp = (this._sort.value || '').split(','); | |
return sortTmp[1] || 'd'; | |
} | |
get isPlaylistSort() { | |
const sortTmp = (this._sort.value || '').split(','); | |
return sortTmp[0] === 'playlist'; | |
} | |
get isOwnerOnly() { | |
return this._form['ownerOnly'].checked; | |
} | |
} | |
css.addStyle(` | |
.is-open .zenzaWatchVideoHeaderPanel .zenzaVideoSearchPanel { | |
top: 120px; | |
right: 32px; | |
} | |
`, {className: 'screenMode for-popup videoSearchPanel', disabled: true}); | |
VideoSearchForm.__css__ = (` | |
.zenzaVideoSearchPanel { | |
pointer-events: auto; | |
position: absolute; | |
top: 32px; | |
right: 8px; | |
padding: 0 8px | |
width: 248px; | |
z-index: 1000; | |
} | |
.zenzaScreenMode_normal .zenzaWatchVideoHeaderPanel.is-onscreen .zenzaVideoSearchPanel, | |
.zenzaScreenMode_big .zenzaWatchVideoHeaderPanel.is-onscreen .zenzaVideoSearchPanel, | |
.zenzaScreenMode_3D .zenzaVideoSearchPanel, | |
.zenzaScreenMode_wide .zenzaVideoSearchPanel, | |
.zenzaWatchVideoHeaderPanel.is-fullscreen .zenzaVideoSearchPanel { | |
top: 64px; | |
} | |
.zenzaVideoSearchPanel:focus-within { | |
background: rgba(50, 50, 50, 0.8); | |
} | |
.zenzaVideoSearchPanel:not(:focus-within) .focusOnly { | |
display: none; | |
} | |
.zenzaVideoSearchPanel .searchInputHead { | |
position: absolute; | |
opacity: 0; | |
pointer-events: none; | |
padding: 4px; | |
transition: transform 0.2s ease, opacity 0.2s ease; | |
} | |
.zenzaVideoSearchPanel .searchInputHead:hover, | |
.zenzaVideoSearchPanel:focus-within .searchInputHead { | |
background: rgba(50, 50, 50, 0.8); | |
} | |
.zenzaVideoSearchPanel .searchInputHead:hover, | |
.zenzaVideoSearchPanel:focus-within .searchInputHead { | |
pointer-events: auto; | |
opacity: 1; | |
transform: translate3d(0, -100%, 0); | |
} | |
.zenzaVideoSearchPanel .searchMode { | |
position: absolute; | |
opacity: 0; | |
} | |
.zenzaVideoSearchPanel .searchModeLabel { | |
cursor: pointer; | |
} | |
.zenzaVideoSearchPanel .searchModeLabel span { | |
display: inline-block; | |
padding: 4px 8px; | |
color: #666; | |
cursor: pointer; | |
border-radius: 8px; | |
border-color: transparent; | |
border-style: solid; | |
border-width: 1px; | |
pointer-events: none; | |
} | |
.zenzaVideoSearchPanel .searchModeLabel:hover span { | |
background: #888; | |
} | |
.zenzaVideoSearchPanel .searchModeLabel input:checked + span { | |
color: #ccc; | |
border-color: currentColor; | |
cursor: default; | |
} | |
.zenzaVideoSearchPanel .searchWord { | |
white-space: nowrap; | |
padding: 4px; | |
} | |
.zenzaVideoSearchPanel .searchWordInput { | |
width: 200px; | |
margin: 0; | |
height: 24px; | |
line-height: 24px; | |
background: transparent; | |
font-size: 16px; | |
padding: 0 4px; | |
color: #ccc; | |
border: 1px solid #ccc; | |
opacity: 0; | |
transition: opacity 0.2s ease; | |
will-change: opacity; | |
} | |
.zenzaVideoSearchPanel .searchWordInput:-webkit-autofill { | |
background: transparent; | |
} | |
.is-mouseMoving .searchWordInput { | |
opacity: 0.5; | |
} | |
.is-mouseMoving .searchWordInput:hover { | |
opacity: 0.8; | |
} | |
.zenzaVideoSearchPanel:focus-within .searchWordInput { | |
opacity: 1 !important; | |
} | |
.zenzaVideoSearchPanel .searchSubmit { | |
width: 34px; | |
margin: 0; | |
padding: 0; | |
font-size: 14px; | |
line-height: 24px; | |
height: 24px; | |
border: solid 1px #ccc; | |
cursor: pointer; | |
background: #888; | |
pointer-events: none; | |
opacity: 0; | |
transform: translate3d(-100%, 0, 0); | |
transition: opacity 0.2s ease, transform 0.2s ease; | |
} | |
.zenzaVideoSearchPanel:focus-within .searchSubmit { | |
pointer-events: auto; | |
opacity: 1; | |
transform: translate3d(0, 0, 0); | |
} | |
.zenzaVideoSearchPanel:focus-within .searchSubmit:hover { | |
transform: scale(1.5); | |
} | |
.zenzaVideoSearchPanel:focus-within .searchSubmit:active { | |
transform: scale(1.2); | |
border-style: inset; | |
} | |
.zenzaVideoSearchPanel .searchClear { | |
display: inline-block; | |
width: 28px; | |
margin: 0; | |
padding: 0; | |
font-size: 16px; | |
line-height: 24px; | |
height: 24px; | |
border: none; | |
cursor: pointer; | |
color: #ccc; | |
background: transparent; | |
pointer-events: none; | |
opacity: 0; | |
transform: translate3d(100%, 0, 0); | |
transition: opacity 0.2s ease, transform 0.2s ease; | |
} | |
.zenzaVideoSearchPanel:focus-within .searchClear { | |
pointer-events: auto; | |
opacity: 1; | |
transform: translate3d(0, 0, 0); | |
} | |
.zenzaVideoSearchPanel:focus-within .searchClear:hover { | |
transform: scale(1.5); | |
} | |
.zenzaVideoSearchPanel:focus-within .searchClear:active { | |
transform: scale(1.2); | |
} | |
.zenzaVideoSearchPanel .searchInputFoot { | |
white-space: nowrap; | |
position: absolute; | |
padding: 4px 0; | |
opacity: 0; | |
padding: 4px; | |
pointer-events: none; | |
transition: transform 0.2s ease, opacity 0.2s ease; | |
transform: translate3d(0, -100%, 0); | |
} | |
.zenzaVideoSearchPanel .searchInputFoot:hover, | |
.zenzaVideoSearchPanel:focus-within .searchInputFoot { | |
pointer-events: auto; | |
opacity: 1; | |
background: rgba(50, 50, 50, 0.8); | |
transform: translate3d(0, 0, 0); | |
} | |
.zenzaVideoSearchPanel .searchSortSelect, | |
.zenzaVideoSearchPanel .searchSortSelect option{ | |
background: #333; | |
color: #ccc; | |
} | |
.zenzaVideoSearchPanel .autoPauseLabel { | |
cursor: pointer; | |
} | |
.zenzaVideoSearchPanel .autoPauseLabel input + span { | |
display: inline-block; | |
pointer-events: none; | |
} | |
`).trim(); | |
VideoSearchForm.__tpl__ = (` | |
<div class="zenzaVideoSearchPanel" id="zenzaVideoSearchPanel"> | |
<form action="javascript: void(0);"> | |
<div class="searchInputHead"> | |
<label class="searchModeLabel"> | |
<input type="radio" name="mode" class="searchMode" value="keyword"> | |
<span>キーワード</span> | |
</label> | |
<label class="searchModeLabel"> | |
<input type="radio" name="mode" class="searchMode" value="tag" | |
id="zenzaVideoSearch-tag" checked="checked"> | |
<span>タグ</span> | |
</label> | |
</div> | |
<div class="searchWord"> | |
<button class="searchClear command" | |
type="button" | |
data-command="clear" | |
title="クリア">✖</button> | |
<input | |
type="text" | |
value="" | |
autocomplete="on" | |
name="word" | |
accesskey="e" | |
placeholder="簡易検索(テスト中)" | |
class="searchWordInput" | |
maxlength="75" | |
> | |
<input | |
type="submit" | |
value="▶" | |
name="post" | |
class="searchSubmit" | |
> | |
</div> | |
<div class="searchInputFoot focusOnly"> | |
<select name="sort" class="searchSortSelect"> | |
<option value="playlist">自動(連続再生用)</option> | |
<option value="f">新しい順</option> | |
<option value="h">人気順</option> | |
<option value="n">最新コメント</option> | |
<option value="r">コメント数</option> | |
<option value="m">マイリスト数</option> | |
<option value="l">長い順</option> | |
<option value="l,a">短い順</option> | |
</select> | |
<label class="autoPauseLabel"> | |
<input type="checkbox" name="ownerOnly" checked="checked"> | |
<span>投稿者の動画のみ</span> | |
</label> | |
</div> | |
</form> | |
</div> | |
`).toString(); | |
class IchibaItemView extends BaseViewComponent { | |
constructor({parentNode}) { | |
super({ | |
parentNode, | |
name: 'IchibaItemView', | |
template: IchibaItemView.__tpl__, | |
css: IchibaItemView.__css__, | |
}); | |
ZenzaWatch.debug.ichiba = this; | |
} | |
_initDom(...args) { | |
super._initDom(...args); | |
this._listContainer = | |
this._view.querySelector('.ichibaItemListContainer .ichibaItemListInner'); | |
this._listContainerDetails = | |
this._view.querySelector('.ichibaItemListContainer .ichibaItemListDetails'); | |
} | |
_onCommand(command, param) { | |
switch (command) { | |
case 'load': | |
this.load(this._videoId); | |
break; | |
default: | |
super._onCommand(command, param); | |
} | |
} | |
load(videoId) { | |
if (this._isLoading) { | |
return; | |
} | |
videoId = videoId || this._videoId; | |
this._isLoading = true; | |
this.addClass('is-loading'); | |
return IchibaLoader.load(videoId) | |
.then(this._onIchibaLoad.bind(this)) | |
.catch(this._onIchibaLoadFail.bind(this)); | |
} | |
clear() { | |
this.removeClass('is-loading is-success is-fail is-empty'); | |
this._listContainer.textContent = ''; | |
} | |
_onIchibaLoad(data) { | |
this.removeClass('is-loading'); | |
const div = document.createElement('div'); | |
div.innerHTML = data.main; | |
Array.from(div.querySelectorAll('[id]')).forEach(elm => { | |
elm.classList.add(`ichiba-${elm.id}`); | |
elm.removeAttribute('id'); | |
}); | |
Array.from(div.querySelectorAll('[style]')) | |
.forEach(elm => elm.removeAttribute('style')); | |
const items = div.querySelectorAll('.ichiba_mainitem'); | |
if (!items || items.length < 1) { | |
this.addClass('is-empty'); | |
this._listContainer.innerHTML = '<h2>貼られている商品はありません</h2>'; | |
} else { | |
this._listContainer.innerHTML = div.innerHTML; | |
} | |
this.addClass('is-success'); | |
this._listContainerDetails.setAttribute('open', 'open'); | |
this._isLoading = false; | |
} | |
_onIchibaLoadFail() { | |
this.removeClass('is-loading'); | |
this.addClass('is-fail'); | |
this._isLoading = false; | |
} | |
get videoId() { | |
return this._videoId; | |
} | |
set videoId(v) { | |
this._videoId = v; | |
} | |
} | |
IchibaItemView.__tpl__ = (` | |
<div class="ZenzaIchibaItemView"> | |
<div class="loadStart"> | |
<div class="loadStartButton command" data-command="load">ニコニコ市場</div> | |
</div> | |
<div class="ichibaLoadingView"> | |
<div class="loading-inner"> | |
<span class="spinner">⌛</span> | |
</div> | |
</div> | |
<div class="ichibaItemListContainer"> | |
<details class="ichibaItemListDetails"> | |
<summary class="ichibaItemSummary loadStartButton">ニコニコ市場</summary> | |
<div class="ichibaItemListInner"></div> | |
</details> | |
</div> | |
</div> | |
`).trim(); | |
css.addStyle(` | |
.ZenzaIchibaItemView .loadStartButton { | |
color: #000; | |
} | |
`, {className: 'screenMode for-popup ichiba', disabled: true}); | |
IchibaItemView.__css__ = (` | |
.ZenzaIchibaItemView { | |
text-align: center; | |
margin: 4px 8px 8px; | |
color: #ccc; | |
} | |
.ZenzaIchibaItemView .loadStartButton { | |
font-size: 24px; | |
padding: 8px 8px; | |
margin: 8px; | |
background: inherit; | |
color: inherit; | |
border: 1px solid #ccc; | |
outline: none; | |
line-height: 20px; | |
border-radius: 8px; | |
cursor: pointer; | |
user-select: none; | |
} | |
.ZenzaIchibaItemView .loadStartButton:active::after { | |
opacity: 0; | |
} | |
.ZenzaIchibaItemView .loadStartButton:active { | |
transform: translate(0, 2px); | |
} | |
.ZenzaIchibaItemView .ichibaLoadingView, | |
.ZenzaIchibaItemView .ichibaItemListContainer { | |
display: none; | |
} | |
.ZenzaIchibaItemView.is-loading { | |
cursor: wait; | |
user-select: none; | |
} | |
.ZenzaIchibaItemView.is-loading * { | |
pointer-events: none; | |
} | |
.ZenzaIchibaItemView.is-loading .ichibaLoadingView { | |
display: block; | |
font-size: 32px; | |
} | |
.ZenzaIchibaItemView.is-loading .loadStart, | |
.ZenzaIchibaItemView.is-loading .ichibaItemListContainer { | |
display: none; | |
} | |
.ZenzaIchibaItemView.is-success { | |
background: none; | |
} | |
.ZenzaIchibaItemView.is-success .ichibaLoadingView, | |
.ZenzaIchibaItemView.is-success .loadStart { | |
display: none; | |
} | |
.ZenzaIchibaItemView.is-success .ichibaItemListContainer { | |
display: block; | |
} | |
.ZenzaIchibaItemView.is-success details[open] { | |
border: 1px solid #666; | |
border-radius: 4px; | |
padding: 0px; | |
} | |
.ZenzaIchibaItemView.is-fail .ichibaLoadingView, | |
.ZenzaIchibaItemView.is-fail .loadStartButton { | |
display: none; | |
} | |
.ZenzaIchibaItemView.is-fail .ichibaItemListContainer { | |
display: block; | |
} | |
.ZenzaIchibaItemView .ichibaItemListContainer { | |
text-align: center; | |
} | |
.ZenzaIchibaItemView .ichibaItemListContainer .ichiba-ichiba_mainpiaitem, | |
.ZenzaIchibaItemView .ichibaItemListContainer .ichiba_mainitem { | |
display: inline-table; | |
width: 220px; | |
margin: 8px; | |
padding: 8px; | |
word-break: break-all; | |
text-shadow: 1px 1px 0 #000; | |
background: #666; | |
border-radius: 4px; | |
} | |
.ZenzaIchibaItemView .price, | |
.ZenzaIchibaItemView .buy, | |
.ZenzaIchibaItemView .click { | |
font-weight: bold; | |
} | |
.ZenzaIchibaItemView a { | |
display: inline-block; | |
font-weight: bold; | |
text-decoration: none; | |
color: #ff9; | |
padding: 2px; | |
} | |
.ZenzaIchibaItemView a:visited { | |
color: #ffd; | |
} | |
.ZenzaIchibaItemView .rowJustify, | |
.ZenzaIchibaItemView .noItem, | |
.ichiba-ichibaMainLogo, | |
.ichiba-ichibaMainHeader, | |
.ichiba-ichibaMainFooter { | |
display: none; | |
} | |
`).trim(); | |
class UaaView extends BaseViewComponent { | |
constructor({parentNode}) { | |
super({ | |
parentNode, | |
name: 'UaaView', | |
template: UaaView.__tpl__, | |
shadow: UaaView._shadow_, | |
css: UaaView.__css__ | |
}); | |
this._state = { | |
isUpdating: false, | |
isExist: false, | |
isSpeaking: false | |
}; | |
this._config = Config.namespace('uaa'); | |
this._bound.load = this.load.bind(this); | |
this._bound.update = this.update.bind(this); | |
} | |
_initDom(...args) { | |
super._initDom(...args); | |
ZenzaWatch.debug.uaa = this; | |
if (!this._shadow) { | |
return; | |
} // ShadowDOM使えなかったらバイバイ | |
const shadow = this._shadow || this._view; | |
this._elm.body = shadow.querySelector('.UaaDetailBody'); | |
} | |
update(videoInfo) { | |
if (!this._shadow || !this._config.props.enable) { | |
return; | |
} | |
if (!this._elm.body) { | |
return; | |
} | |
if (this._state.isUpdating) { | |
return; | |
} | |
this.setState({isUpdating: true}); | |
this._props.videoInfo = videoInfo; | |
this._props.videoId = videoInfo.videoId; | |
window.setTimeout(() => { | |
this.load(videoInfo); | |
}, 5000); | |
} | |
load(videoInfo) { | |
const videoId = videoInfo.videoId; | |
return UaaLoader.load(videoId, {limit: 50}) | |
.then(this._onLoad.bind(this, videoId)) | |
.catch(this._onFail.bind(this, videoId)); | |
} | |
clear() { | |
this.setState({isUpdating: false, isExist: false, isSpeaking: false}); | |
if (!this._elm.body) { | |
return; | |
} | |
this._elm.body.textContent = ''; | |
} | |
_onLoad(videoId, result) { | |
if (this._props.videoId !== videoId) { | |
return; | |
} | |
this.setState({isUpdating: false}); | |
const data = result ? result.data : null; | |
if (!data || data.sponsors.length < 1) { | |
return; | |
} | |
const df = this.df = this.df || document.createDocumentFragment(); | |
const div = document.createElement('div'); | |
div.className = 'screenshots'; | |
let idx = 0, screenshots = 0; | |
data.sponsors.forEach(u => { | |
if (!u.auxiliary.bgVideoPosition || idx >= 4) { | |
return; | |
} | |
u.added = true; | |
div.append(this._createItem(u, idx++)); | |
screenshots++; | |
}); | |
div.setAttribute('data-screenshot-count', screenshots); | |
df.append(div); | |
data.sponsors.forEach(u => { | |
if (!u.auxiliary.bgVideoPosition || u.added) { | |
return; | |
} | |
u.added = true; | |
df.append(this._createItem(u, idx++)); | |
}); | |
data.sponsors.forEach(u => { | |
if (u.added) { | |
return; | |
} | |
u.added = true; | |
df.append(this._createItem(u, idx++)); | |
}); | |
this._elm.body.innerHTML = ''; | |
this._elm.body.append(df); | |
this.setState({isExist: true}); | |
} | |
_createItem(data, idx) { | |
const df = document.createElement('div'); | |
const contact = document.createElement('span'); | |
contact.textContent = data.advertiserName; | |
contact.className = 'contact'; | |
df.className = 'item'; | |
const aux = data.auxiliary; | |
const bgkeyframe = aux.bgVideoPosition || 0; | |
if (data.message) { | |
data.title = data.message; | |
} | |
df.setAttribute('data-index', idx); | |
if (bgkeyframe && idx < 4) { | |
const sec = parseFloat(bgkeyframe); | |
df.setAttribute('data-time', textUtil.secToTime(sec)); | |
df.classList.add('clickable', 'command', 'other'); | |
Object.assign(df.dataset, { command: 'seek', type: 'number', param: sec }); | |
contact.setAttribute('title', `${data.message}(${textUtil.secToTime(sec)})`); | |
this._props.videoInfo.getCurrentVideo() | |
.then(url => ZenzaWatch.util.VideoCaptureUtil.capture(url, sec)) | |
.then(screenshot => { | |
const cv = document.createElement('canvas'); | |
const ct = cv.getContext('2d'); | |
cv.width = screenshot.width; | |
cv.height = screenshot.height; | |
cv.className = 'screenshot command clickable'; | |
Object.assign(cv.dataset, { command: 'seek', type: 'number', param: sec }); | |
ct.fillStyle = 'rgb(32, 32, 32)'; | |
ct.fillRect(0, 0, cv.width, cv.height); | |
ct.drawImage(screenshot, 0, 0); | |
df.classList.add('has-screenshot'); | |
df.classList.remove('clickable', 'other'); | |
df.append(cv); | |
}).catch(() => {}); | |
} else if (bgkeyframe) { | |
const sec = parseFloat(bgkeyframe); | |
df.classList.add('clickable', 'command', 'other'); | |
Object.assign(df.dataset, { command: 'seek', type: 'number', param: sec }); | |
contact.setAttribute('title', `${data.message}(${textUtil.secToTime(sec)})`); | |
} else { | |
df.classList.add('other'); | |
} | |
df.append(contact); | |
return df; | |
} | |
_onFail(videoId) { | |
if (this._props.videoId !== videoId) { | |
return; | |
} | |
this.setState({isUpdating: false}); | |
} | |
_onCommand(command, param) { | |
switch (command) { | |
default: | |
super._onCommand(command, param); | |
} | |
} | |
} | |
UaaView._shadow_ = (` | |
<style> | |
.UaaDetails, | |
.UaaDetails * { | |
box-sizing: border-box; | |
user-select: none; | |
} | |
.UaaDetails .clickable { | |
cursor: pointer; | |
} | |
.UaaDetails .clickable:active { | |
transform: translate(0, 2px); | |
box-shadow: none; | |
} | |
.UaaDetails { | |
opacity: 0; | |
pointer-events: none; | |
max-height: 0; | |
margin: 0 8px 0; | |
color: #ccc; | |
overflow: hidden; | |
text-align: center; | |
word-break: break-all; | |
} | |
.UaaDetails.is-Exist { | |
display: block; | |
pointer-events: auto; | |
max-height: 800px; | |
padding: 4px; | |
opacity: 1; | |
transition: opacity 0.4s linear 0.4s, max-height 1s ease-in, margin 0.4s ease-in; | |
} | |
.UaaDetails.is-Exist[open] { | |
border: 1px solid #666; | |
border-radius: 4px; | |
overflow: auto; | |
} | |
.UaaDetails .uaaSummary { | |
height: 38px; | |
margin: 4px 4px 8px; | |
color: inherit; | |
outline: none; | |
border: 1px solid #ccc; | |
letter-spacing: 12px; | |
line-height: 38px; | |
font-size: 24px; | |
text-align: center; | |
cursor: pointer; | |
border-radius: 8px; | |
} | |
.UaaDetails .uaaDetailBody { | |
margin: auto; | |
} | |
.UaaDetails .item { | |
display: inline; | |
width: inherit; | |
margin: 0 4px 0 0; | |
} | |
.UaaDetails .item.has-screenshot { | |
position: relative; | |
display:inline-block; | |
margin: 4px; | |
} | |
.UaaDetails .item.has-screenshot::after { | |
content: attr(data-time); | |
position: absolute; | |
right: 0; | |
bottom: 0; | |
padding: 2px 4px; | |
background: #000; | |
color: #ccc; | |
font-size: 12px; | |
line-height: 14px; | |
} | |
.UaaDetails .item.has-screenshot:hover::after { | |
opacity: 0; | |
} | |
.UaaDetails .contact { | |
display: inline-block; | |
color: #fff; | |
font-weight: bold; | |
font-size: 16px; | |
text-align: center; | |
user-select: none; | |
word-break: break-all; | |
} | |
.UaaDetails .item.has-screenshot .contact { | |
position: absolute; | |
text-align: center; | |
width: 100%; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
color: #fff; | |
text-shadow: 1px 1px 1px #000; | |
text-stroke: 1px #000; | |
-webkit-text-stroke: 1px #000; | |
pointer-events: none; | |
font-size: 16px; | |
} | |
.UaaDetails .item.has-screenshot:hover .contact { | |
display: none; | |
} | |
.UaaDetails .item.other { | |
display: inline-block; | |
border: none; | |
width: inherit; | |
margin: 0; | |
padding: 2px 4px; | |
line-height: normal; | |
min-height: inherit; | |
text-align: left; | |
} | |
.UaaDetails .item.is-speaking { | |
text-decoration: underline; | |
} | |
.UaaDetails .item.has-screenshot.is-speaking { | |
outline: none; | |
transition: transform 0.2s ease; | |
transform: scale(1.2); | |
z-index: 1000; | |
} | |
.UaaDetails .item .contact { | |
display: inline; | |
padding: 2px 4px; | |
width: auto; | |
font-size: 12px; | |
text-stroke: 0; | |
color: inherit; /*#ccc;*/ | |
outline-offset: -2px; | |
} | |
.UaaDetails .item.other.clickable { | |
display: inline-block; | |
padding: 2px 4px; | |
margin: 0 4px; | |
} | |
.UaaDetails .item.other.clickable .contact { | |
display: inline-block; | |
color: #ffc; | |
} | |
.UaaDetails .item.other.clickable .contact::after { | |
content: attr(title); | |
color: #ccc; | |
font-weight: normal; | |
margin: 0 4px; | |
} | |
.UaaDetails .screenshot { | |
display: block; | |
width: 128px; | |
margin: 0; | |
vertical-align: middle; | |
cursor: pointer; | |
} | |
.screenshots[data-screenshot-count="1"] .screenshot { | |
width: 192px; | |
} | |
.zenzaScreenMode_sideView .is-notFullscreen .UaaDetails { | |
color: #000; | |
} | |
:host-context(.zenzaScreenMode_sideView .is-notFullscreen) .UaaDetails { | |
color: #000; | |
} | |
</style> | |
<details class="root UaaDetails"> | |
<summary class="uaaSummary clickable">提供</summary> | |
<div class="UaaDetailBody"></div> | |
</details> | |
`).trim(); | |
UaaView.__tpl__ = ('<div class="uaaView"></div>').trim(); | |
UaaView.__css__ = (` | |
uaaView { | |
display: none; | |
} | |
uaaView.is-Exist { | |
display: block; | |
} | |
`).trim(); | |
class RelatedInfoMenu extends BaseViewComponent { | |
constructor({parentNode, isHeader}) { | |
super({ | |
parentNode, | |
name: 'RelatedInfoMenu', | |
template: '<div class="RelatedInfoMenu" tabindex="-1"></div>', | |
shadow: RelatedInfoMenu._shadow_, | |
css: RelatedInfoMenu.__css__ | |
}); | |
this._state = {}; | |
this._bound.update = this.update.bind(this); | |
this._bound._onBodyClick = _.debounce(this._onBodyClick.bind(this), 0); | |
this.setState({isHeader}); | |
} | |
_initDom(...args) { | |
super._initDom(...args); | |
ClassList(this._view).toggle('is-Edge', /edge/i.test(navigator.userAgent)); | |
const shadow = this._shadow || this._view; | |
this._elm.body = shadow.querySelector('.RelatedInfoMenuBody'); | |
this._elm.summary = shadow.querySelector('summary'); | |
shadow.addEventListener('click', e => { | |
e.stopPropagation(); | |
}); | |
this._elm.summary.addEventListener('click', _.debounce(() => { | |
if (shadow.open) { | |
document.body.addEventListener('mouseup', this._bound._onBodyClick, {once: true}); | |
this.emit('open'); | |
} | |
}, 100)); | |
this._ginzaLink = shadow.querySelector('.ginzaLink'); | |
this._originalLink = shadow.querySelector('.originalLink'); | |
this._twitterLink = shadow.querySelector('.twitterHashLink'); | |
this._parentVideoLink = shadow.querySelector('.parentVideoLink'); | |
} | |
_onBodyClick() { | |
const shadow = this._shadow || this._view; | |
shadow.open = false; | |
document.body.removeEventListener('mouseup', this._bound._onBodyClick); | |
this.emit('close'); | |
} | |
update(videoInfo) { | |
const shadow = this._shadow || this._view; | |
shadow.open = false; | |
this._currentWatchId = videoInfo.watchId; | |
this._currentVideoId = videoInfo.videoId; | |
this.setState({ | |
isParentVideoExist: videoInfo.hasParentVideo, | |
isCommunity: videoInfo.isCommunityVideo, | |
isMymemory: videoInfo.isMymemory | |
}); | |
const vid = this._currentVideoId; | |
const wid = this._currentWatchId; | |
this._ginzaLink.setAttribute('href', `//www.nicovideo.jp/watch/${wid}`); | |
this._originalLink.setAttribute('href', `//www.nicovideo.jp/watch/${vid}`); | |
this._twitterLink.setAttribute('href', `https://twitter.com/hashtag/${vid}`); | |
this._parentVideoLink.setAttribute('href', `//commons.nicovideo.jp/tree/${vid}`); | |
this.emit('close'); | |
} | |
_onCommand(command, param) { | |
let url; | |
const shadow = this._shadow || this._view; | |
shadow.open = false; | |
switch (command) { | |
case 'watch-ginza': | |
window.open(this._ginzaLink.href, 'watchGinza'); | |
super._onCommand('pause'); | |
break; | |
case 'open-uad': | |
url = `//nicoad.nicovideo.jp/video/publish/${this._currentWatchId}?frontend_id=6&frontend_version=0&zenza_watch`; | |
window.open(url, '', 'width=428, height=600, toolbar=no, scrollbars=1'); | |
break; | |
case 'open-twitter-hash': | |
window.open(this._twitterLink.href); | |
break; | |
case 'open-parent-video': | |
window.open(this._parentVideoLink.href); | |
break; | |
case 'copy-video-watch-url': | |
super._onCommand(command, param); | |
super._onCommand('notify', 'コピーしました'); | |
break; | |
case 'open-original-video': | |
super._onCommand('openNow', this._currentVideoId); | |
break; | |
default: | |
super._onCommand(command, param); | |
} | |
this.emit('close'); | |
} | |
} | |
RelatedInfoMenu._css_ = ('').trim(); | |
RelatedInfoMenu._shadow_ = (` | |
<style> | |
.RelatedInfoMenu, | |
.RelatedInfoMenu * { | |
box-sizing: border-box; | |
user-select: none; | |
} | |
.RelatedInfoMenu { | |
display: inline-block; | |
padding: 8px; | |
font-size: 16px; | |
cursor: pointer; | |
} | |
.RelatedInfoMenu summary { | |
display: inline-block; | |
background: transparent; | |
color: #333; | |
padding: 4px 8px; | |
border-radius: 4px; | |
outline: none; | |
border: 1px solid #ccc; | |
} | |
.RelatedInfoMenu ul { | |
list-style-type: none; | |
padding-left: 32px; | |
} | |
.RelatedInfoMenu li { | |
padding: 4px; | |
} | |
.RelatedInfoMenu li > .command { | |
display: inline-block; | |
text-decoration: none; | |
color: #ccc; | |
} | |
.RelatedInfoMenu li > .command:hover { | |
text-decoration: underline; | |
} | |
.RelatedInfoMenu li > .command:hover::before { | |
content: '▷'; | |
position: absolute; | |
transform: translate(-100%, 0); | |
} | |
.RelatedInfoMenu .originalLinkMenu, | |
.RelatedInfoMenu .parentVideoMenu { | |
display: none; | |
} | |
.RelatedInfoMenu.is-Community .originalLinkMenu, | |
.RelatedInfoMenu.is-Mymemory .originalLinkMenu, | |
.RelatedInfoMenu.is-ParentVideoExist .parentVideoMenu { | |
display: block; | |
} | |
.zenzaScreenMode_sideView .is-fullscreen .RelatedInfoMenu summary{ | |
background: #888; | |
} | |
:host-context(.zenzaScreenMode_sideView .is-fullscreen) .RelatedInfoMenu summary { | |
background: #888; | |
} | |
/* :host-contextで分けたいけどFirefox対応のため */ | |
.RelatedInfoMenu.is-Header { | |
font-size: 13px; | |
padding: 0 8px; | |
} | |
.RelatedInfoMenu.is-Header summary { | |
background: #666; | |
color: #ccc; | |
padding: 0 8px; | |
border: none; | |
} | |
.RelatedInfoMenu.is-Header[open] { | |
background: rgba(80, 80, 80, 0.9); | |
} | |
.RelatedInfoMenu.is-Header ul { | |
font-size: 16px; | |
line-height: 20px; | |
} | |
:host-context(.zenzaWatchVideoInfoPanel) .RelatedInfoMenu li > .command { | |
color: #222; | |
} | |
.zenzaWatchVideoInfoPanel .RelatedInfoMenu li > .command { | |
color: #222; | |
} | |
/* for Edge */ | |
.is-Edge .RelatedInfoMenuBody { | |
display: none; | |
color: #ccc; | |
background: rgba(80, 80, 80, 0.9); | |
} | |
.RelatedInfoMenu[open] .RelatedInfoMenuBody, | |
.RelatedInfoMenu:focus .RelatedInfoMenuBody, | |
.RelatedInfoMenuBody:hover { | |
display: block; | |
} | |
</style> | |
<details class="root RelatedInfoMenu"> | |
<summary class="RelatedInfoMenuSummary clickable">関連メニュー</summary> | |
<div class="RelatedInfoMenuBody"> | |
<ul> | |
<li class="ginzaMenu"> | |
<a class="ginzaLink command" | |
rel="noopener" data-command="watch-ginza">公式プレイヤーで開く</a> | |
</li> | |
<li class="uadMenu"> | |
<span class="uadLink command" | |
rel="noopener" data-command="open-uad">ニコニ広告で宣伝</span> | |
</li> | |
<li class="twitterHashMenu"> | |
<a class="twitterHashLink command" | |
rel="noopener" data-command="open-twitter-hash">twitterの反応を見る</a> | |
</li> | |
<li class="originalLinkMenu"> | |
<a class="originalLink command" | |
rel="noopener" data-command="open-original-video">元動画を開く</a> | |
</li> | |
<li class="parentVideoMenu"> | |
<a class="parentVideoLink command" | |
rel="noopener" data-command="open-parent-video">親作品・コンテンツツリー</a> | |
</li> | |
<li class="copyVideoWatchUrlMenu"> | |
<span class="copyVideoWatchUrlLink command" | |
rel="noopener" data-command="copy-video-watch-url">動画URLをコピー</span> | |
</li> | |
</ul> | |
</div> | |
</details> | |
`).trim(); | |
class VideoMetaInfo extends BaseViewComponent { | |
constructor({parentNode}) { | |
super({ | |
parentNode, | |
name: 'VideoMetaInfo', | |
template: '<div class="VideoMetaInfo"></div>', | |
shadow: VideoMetaInfo._shadow_, | |
css: VideoMetaInfo.__css__ | |
}); | |
this._state = {}; | |
this._bound.update = this.update.bind(this); | |
} | |
_initDom(...args) { | |
super._initDom(...args); | |
const shadow = this._shadow || this._view; | |
this._elm = Object.assign({}, this._elm, { | |
postedAt: shadow.querySelector('.postedAt'), | |
body: shadow.querySelector('.videoMetaInfo'), | |
viewCount: shadow.querySelector('.viewCount'), | |
commentCount: shadow.querySelector('.commentCount'), | |
mylistCount: shadow.querySelector('.mylistCount') | |
}); | |
} | |
update(videoInfo) { | |
this._elm.postedAt.textContent = videoInfo.postedAt; | |
const count = videoInfo.count; | |
this.updateVideoCount(count); | |
} | |
updateVideoCount({comment, view, mylist}) { | |
const addComma = m => m.toLocaleString ? m.toLocaleString() : m; | |
if (typeof comment === 'number') { | |
this._elm.commentCount.textContent = addComma(comment); | |
} | |
if (typeof view === 'number') { | |
this._elm.viewCount.textContent = addComma(view); | |
} | |
if (typeof mylist === 'number') { | |
this._elm.mylistCount.textContent = addComma(mylist); | |
} | |
} | |
} | |
VideoMetaInfo._css_ = ('').trim(); | |
VideoMetaInfo._shadow_ = (` | |
<style> | |
.VideoMetaInfo .postedAtOuter { | |
display: inline-block; | |
margin-right: 24px; | |
} | |
.VideoMetaInfo .postedAt { | |
font-weight: bold | |
} | |
.VideoMetaInfo .countOuter { | |
white-space: nowrap; | |
} | |
.VideoMetaInfo .countOuter .column { | |
display: inline-block; | |
white-space: nowrap; | |
} | |
.VideoMetaInfo .count { | |
font-weight: bolder; | |
} | |
.userVideo .channelVideo, | |
.channelVideo .userVideo | |
{ | |
display: none !important; | |
} | |
:host-context(.userVideo) .channelVideo, | |
:host-context(.channelVideo) .userVideo | |
{ | |
display: none !important; | |
} | |
</style> | |
<div class="VideoMetaInfo root"> | |
<span class="postedAtOuter"> | |
<span class="userVideo">投稿日:</span> | |
<span class="channelVideo">配信日:</span> | |
<span class="postedAt"></span> | |
</span> | |
<span class="countOuter"> | |
<span class="column">再生: <span class="count viewCount"></span></span> | |
<span class="column">コメント: <span class="count commentCount"></span></span> | |
<span class="column">マイリスト: <span class="count mylistCount"></span></span> | |
</span> | |
</div> | |
`); | |
const initializeGinzaSlayer = (dialog, query) => { | |
uq('.notify_update_flash_playerm, #external_nicoplayer').remove(); | |
const watchId = nicoUtil.getWatchId(); | |
const options = {}; | |
if (!isNaN(query.from)) { | |
options.currentTime = parseFloat(query.from, 10); | |
} | |
const v = document.querySelector('#MainVideoPlayer video'); | |
v && v.pause(); | |
dialog.open(watchId, options); | |
}; | |
const {initialize} = (() => { | |
class HoverMenu { | |
constructor(param) { | |
this.initialize(param); | |
} | |
initialize(param) { | |
this._playerConfig = param.playerConfig; | |
const $view = this._$view = uq( | |
'<zen-button class="ZenButton"><div class="ZenButtonInner scalingUI">Zen</div></zen-button>' | |
); | |
if (!nicoUtil.isGinzaWatchUrl() && | |
this._playerConfig.props.overrideWatchLink && | |
location && location.host.endsWith('.nicovideo.jp')) { | |
this._overrideWatchLink(); | |
} else { | |
this._onHoverEnd = _.debounce(this._onHoverEnd.bind(this), 500); | |
$view.on( | |
location.host.includes('google') ? 'mouseup' : 'click', this._onClick.bind(this)); | |
ZenzaWatch.emitter.on('hideHover', () => $view.removeClass('show')); | |
uq('body') | |
.on('mouseover', this._onHover.bind(this)) | |
.on('mouseover', this._onHoverEnd) | |
.on('mouseout', this._onMouseout.bind(this)) | |
.append($view); | |
} | |
} | |
setPlayer(player) { | |
this._player = player; | |
if (this._playerResolve) { | |
this._playerResolve(player); | |
} | |
} | |
_getPlayer() { | |
if (this._player) { | |
return Promise.resolve(this._player); | |
} | |
if (!this._playerPromise) { | |
this._playerPromise = new Promise(resolve => { | |
this._playerResolve = resolve; | |
}); | |
} | |
return this._playerPromise; | |
} | |
_closest(target) { | |
return target.closest('a[href*="watch/"],a[href*="nico.ms/"],.UadVideoItem-link'); | |
} | |
_onHover (e) { | |
const target = this._closest(e.target); | |
if (target) { | |
this._hoverElement = target; | |
} | |
} | |
_onMouseout (e) { | |
if (this._hoverElement === this._closest(e.target)) { | |
this._hoverElement = null; | |
} | |
} | |
_onHoverEnd (e) { | |
if (!this._hoverElement) { return; } | |
const target = this._closest(e.target); | |
if (this._hoverElement !== target) { | |
return; | |
} | |
if (!target || target.classList.contains('noHoverMenu')) { | |
return; | |
} | |
let href = target.dataset.href || target.href; | |
let watchId = nicoUtil.getWatchId(href); | |
let host = target.hostname; | |
if (!['www.nicovideo.jp', 'sp.nicovideo.jp', 'nico.ms'].includes(host)) { | |
return; | |
} | |
this._query = nicoUtil.parseWatchQuery((target.search || '').substr(1)); | |
if (!watchId || !watchId.match(/^[a-z0-9]+$/)) { | |
return; | |
} | |
if (watchId.startsWith('lv')) { | |
return; | |
} | |
this._watchId = watchId; | |
const offset = target.getBoundingClientRect(); | |
this._$view.css({ | |
top: cssUtil.px(offset.top + window.pageYOffset), | |
left: cssUtil.px(offset.left + window.pageXOffset) | |
}).addClass('show'); | |
document.body.addEventListener('click', () => this._$view.removeClass('show'), {once: true}); | |
} | |
_onClick (e) { | |
const watchId = this._watchId; | |
if (e.ctrlKey) { | |
return; | |
} | |
if (e.shiftKey) { | |
this._send(watchId); | |
} else { | |
this._open(watchId); | |
} | |
} | |
open (watchId, params) { | |
this._open(watchId, params); | |
} | |
async _open (watchId, params) { | |
this._playerOption = Object.assign({ | |
economy: this._playerConfig.getValue('forceEconomy'), | |
query: this._query, | |
eventType: 'click' | |
}, params); | |
const player = await this._getPlayer(); | |
if (this._playerConfig.getValue('enableSingleton')) { | |
ZenzaWatch.external.sendOrOpen(watchId, this._playerOption); | |
} else { | |
player.open(watchId, this._playerOption); | |
} | |
} | |
send (watchId, params) { | |
this._send(watchId, params); | |
} | |
async _send (watchId, params) { | |
await this._getPlayer(); | |
ZenzaWatch.external.send(watchId, Object.assign({query: this._query}, params)); | |
} | |
_overrideWatchLink () { | |
if (!!document.querySelector('.UserPageHeader')) { | |
console.nicoru('user page'); | |
const blockNavigation = e => { | |
if (e.ctrlKey) { return; } | |
e.preventDefault(); | |
}; | |
uq('body').on('mouseover', e => { | |
const target = e.target; | |
if (target.tagName !== 'A' || !target.closest('.NicorepoItem_video')) { | |
return; | |
} | |
target.removeEventListener('click', blockNavigation); | |
target.addEventListener('click', blockNavigation); | |
}); | |
} | |
uq('body').on('click', e => { | |
if (e.ctrlKey) { | |
return; | |
} | |
const target = this._closest(e.target); | |
if (!target || target.classList.contains('noHoverMenu')) { | |
return; | |
} | |
if (target.closest('.NicorepoItem_video')) { | |
e.stopPropagation(); | |
} | |
let href = target.dataset.href || target.href; | |
let watchId = nicoUtil.getWatchId(href); | |
let host = target.hostname; | |
if (!['www.nicovideo.jp', 'sp.nicovideo.jp', 'nico.ms'].includes(host)) { | |
return; | |
} | |
this._query = nicoUtil.parseWatchQuery((target.search || '').substr(1)); | |
if (!watchId || !watchId.match(/^[a-z0-9]+$/)) { | |
return; | |
} | |
if (watchId.startsWith('lv')) { | |
return; | |
} | |
e.preventDefault(); | |
if (e.shiftKey) { | |
this._send(watchId); | |
} else { | |
this._open(watchId); | |
} | |
window.setTimeout(() => ZenzaWatch.emitter.emit('hideHover'), 1500); | |
}); | |
} | |
} | |
const isOverrideGinza = () => { | |
if (window.name === 'watchGinza') { | |
return false; | |
} | |
if (Config.props.overrideGinza && nicoUtil.isZenzaPlayableVideo()) { | |
return true; | |
} | |
return false; | |
}; | |
const initWorker = () => { | |
if (!location.host.endsWith('.nicovideo.jp')) { return; } | |
CommentLayoutWorker.getInstance(); | |
ThumbInfoLoader.load('sm9'); | |
window.console.time('init Workers'); | |
return Promise.all([ | |
StoryboardWorker.initWorker(), | |
VideoSessionWorker.initWorker(), | |
StoryboardCacheDb.initWorker(), | |
WatchInfoCacheDb.initWorker() | |
]).then(() => window.console.timeEnd('init Workers')); | |
}; | |
const replaceRedirectLinks = async () => { | |
await uq.ready(); | |
uq('a[href*="www.flog.jp/j.php/http://"]').forEach(a => { | |
a.href = a.href.replace(/^.*https?:/, ''); | |
}); | |
uq('a[href*="rd.nicovideo.jp/cc/"]').forEach(a => { | |
const href = a.href; | |
const m = /cc_video_id=([a-z0-9+]+)/.exec(href); | |
if (m) { | |
const watchId = m[1]; | |
if (!watchId.startsWith('lv')) { | |
a.href = `//www.nicovideo.jp/watch/${watchId}`; | |
} | |
} | |
}); | |
if (window.Nico && window.Nico.onReady) { | |
window.Nico.onReady(() => { | |
let shuffleButton; | |
const query = 'a[href*="continuous=1"]'; | |
const addShufflePlaylistLink = _.debounce(() => { | |
if (shuffleButton) { | |
return; | |
} | |
let $a = uq(query); | |
if (!$a.length) { | |
return false; | |
} | |
const a = $a[0]; | |
const search = (a.search || '').substr(1); | |
const css = { | |
'display': 'inline-block', | |
'padding': '8px 6px' | |
}; | |
const $shuffle = uq.html(a.outerHTML).text('シャッフル再生') | |
.addClass('zenzaPlaylistShuffleStart') | |
.attr('href', `//www.nicovideo.jp/watch/1470321133?${search}&shuffle=1`) | |
.css(css); | |
$a.css(css).after($shuffle); | |
shuffleButton = $shuffle; | |
return true; | |
}, 100); | |
addShufflePlaylistLink(); | |
const container = uq('#myContBody, #SYS_box_mylist_header')[0]; | |
if (!container) { return; } | |
new MutationObserver(records => { | |
for (const rec of records) { | |
const changed = [].concat(Array.from(rec.addedNodes), Array.from(rec.removedNodes)); | |
if (changed.some(i => i.querySelector && i.querySelector(query))) { | |
shuffleButton = null; | |
addShufflePlaylistLink(); | |
return; | |
} | |
} | |
}).observe(container, {childList: true}); | |
}); | |
} | |
if (location.host === 'www.nicovideo.jp' && nicoUtil.getMypageVer() === 'spa') { | |
await uq.ready(); // DOMContentLoaded | |
let shuffleButton; | |
const query = '.ContinuousPlayButton'; | |
const addShufflePlaylistLink = async () => { | |
const lp = location.pathname; | |
if (!lp.startsWith('/my/watchlater') && !lp.includes('/mylist')) { | |
return; | |
} | |
if (shuffleButton && shuffleButton[0].parentNode && shuffleButton[0].parentNode.parentNode) { | |
return; | |
} | |
const $a = uq(query); | |
if (!$a.length) { | |
return false; | |
} | |
if (!shuffleButton) { | |
const $shuffle = uq.html($a[0].outerHTML).text('シャッフル再生') | |
.addClass('zenzaPlaylistShuffleStart'); | |
shuffleButton = $shuffle; | |
} | |
const mylistId = lp.replace(/^.*\//, ''); | |
const playlistType = mylistId ? 'mylist' : 'deflist'; | |
shuffleButton.attr('href', | |
`//www.nicovideo.jp/watch/1470321133?group_id=${mylistId}&playlist_type=${playlistType}&continuous=1&shuffle=1`); | |
$a.before(shuffleButton); | |
return true; | |
}; | |
setInterval(addShufflePlaylistLink, 1000); | |
} | |
if (location.host === 'www.nicovideo.jp' && | |
(location.pathname.indexOf('/search/') === 0 || location.pathname.indexOf('/tag/') === 0)) { | |
let $autoPlay = uq('.autoPlay'); | |
let $target = $autoPlay.find('a'); | |
let search = (location.search || '').substr(1); | |
let href = $target.attr('href') + '&' + search; | |
$target.attr('href', href); | |
let $shuffle = $autoPlay.clone(); | |
let a = $target[0]; | |
$shuffle.find('a').attr({ | |
'href': '/watch/1483135673' + a.search + '&shuffle=1' | |
}).text('シャッフル再生'); | |
$autoPlay.after($shuffle); | |
window.setTimeout(() => { | |
uq('.nicoadVideoItem').forEach(item => { | |
const pointLink = item.querySelector('.count .value a'); | |
if (!pointLink) { | |
return; | |
} | |
const {pathname} = textUtil.parseUrl(pointLink); | |
const videoId = pathname.replace(/^.*\//, ''); | |
uq(item) | |
.find('a[data-link]').attr('href', `//www.nicovideo.jp/watch/${videoId}`); | |
}); | |
}, 3000); | |
} | |
if (location.host === 'ch.nicovideo.jp') { | |
uq('#sec_current a.item').closest('li').forEach(li => { | |
let $li = uq(li), $img = $li.find('img'); | |
let thumbnail = $img.attr('src') || $img.attr('data-original') || ''; | |
let $a = $li.find('a'); | |
let m = /smile\?i=([0-9]+)/.exec(thumbnail); | |
if (m) { | |
$a[0].href = `//www.nicovideo.jp/watch/so${m[1]}`; | |
} | |
}); | |
uq('.playerNavContainer .video img').forEach(img => { | |
let video = img.closest('.video'); | |
if (!video) { | |
return; | |
} | |
let thumbnail = img.src || img.dataset.original || ''; | |
let m = /smile\?i=([0-9]+)/.exec(thumbnail); | |
if (m) { | |
let $a = | |
uq('<a class="more zen" rel="noopener" target="_blank">watch</a>') | |
.css('right', cssUtil.px(128)) | |
.attr('href', `//www.nicovideo.jp/watch/so${m[1]}`); | |
uq(video).find('.more').after($a); | |
} | |
}); | |
} | |
}; | |
const initialize = async function (){ | |
window.console.log('%cinitialize ZenzaWatch...', 'background: lightgreen; '); | |
domEvent.dispatchCustomEvent( | |
document.body, 'BeforeZenzaWatchInitialize', window.ZenzaWatch, {bubbles: true, composed: true}); | |
cssUtil.addStyle(CONSTANT.COMMON_CSS, {className: 'common'}); | |
initializeBySite(); | |
replaceRedirectLinks(); | |
const query = textUtil.parseQuery(START_PAGE_QUERY); | |
await uq.ready(); // DOMContentLoaded | |
const isWatch = util.isGinzaWatchUrl() && | |
(!!document.getElementById('watchAPIDataContainer') || | |
!!document.getElementById('js-initial-watch-data')); | |
const hoverMenu = global.debug.hoverMenu = new HoverMenu({playerConfig: Config}); | |
await Promise.all([ | |
NicoComment.offscreenLayer.get(Config), | |
global.emitter.promise('lit-html'), | |
initWorker() | |
]); | |
document.body.classList.toggle('is-watch', isWatch); | |
const dialog = initializeDialogPlayer(Config); | |
hoverMenu.setPlayer(dialog); | |
if (isWatch) { | |
if (isOverrideGinza()) { | |
initializeGinzaSlayer(dialog, query); | |
} | |
if (window.name === 'watchGinza') { | |
window.name = ''; | |
} | |
} | |
initializeMessage(dialog); | |
WatchPageHistory.initialize(dialog); | |
initializeExternal(dialog, Config, hoverMenu); | |
if (!isWatch) { | |
initializeLastSession(dialog); | |
} | |
CustomElements.initialize(); | |
window.ZenzaWatch.ready = true; | |
global.emitter.emitAsync('ready'); | |
global.emitter.emitResolve('init'); | |
domEvent.dispatchCustomEvent( | |
document.body, 'ZenzaWatchInitialize', window.ZenzaWatch, {bubbles: true, composed: true}); | |
}; | |
const initializeMessage = player => { | |
const config = Config; | |
const bcast = BroadcastEmitter; | |
const onBroadcastMessage = (cmd, type, sessionId) => { | |
const isLast = player.isLastOpenedPlayer; | |
const isOpen = player.isOpen; | |
const {command, params, requestId, now} = cmd; | |
let result; | |
const localNow = Date.now(); | |
if (command === 'hello') { | |
window.console.log( | |
'%cHELLO! \ntime: %s (%smsec)\nmessage: %s \nfrom: %s\nurl: %s\n', 'font-weight: bold;', | |
new Date(params.now).toLocaleString(), localNow - now, | |
params.message, params.from, params.url, | |
{command, isLast, isOpen}); | |
result = {status: 'ok'}; | |
} else if (command === 'sendExecCommand' && | |
(params.command === 'echo' || (isLast && isOpen))) { | |
result = player.execCommand(params.command, params.params); | |
} else if (command === 'ping' && (params.force || (isLast && isOpen))) { | |
window.console.info('pong!'); | |
result = {status: 'ok'}; | |
} else if (command === 'pong') { | |
result = bcast.emitResolve('ping', params); | |
} else if (command === 'notifyClose' && isOpen) { | |
result = player.refreshLastPlayerId(); | |
return; | |
} else if (command === 'notifyOpen') { | |
config.refresh('lastPlayerId'); | |
return; | |
} else if (command ==='pushHistory') { | |
const {path, title} = params; | |
WatchPageHistory.pushHistoryAgency(path, title); | |
} else if (command === 'openVideo' && isLast) { | |
const {watchId, query, eventType} = params; | |
player.open(watchId, {autoCloseFullScreen: false, query, eventType}); | |
} else if (command === 'messageResult') { | |
if (bcast.hasPromise(params.sessionId)) { | |
params.status === 'ok' ? | |
bcast.emitResolve(params.sessionId, params) : | |
bcast.emitReject(params.sessionId, params); | |
} | |
return; | |
} else { | |
return; | |
} | |
result = result || {status: 'ok'}; | |
Object.assign(result, { | |
playerId: player.getId(), | |
title: document.title, | |
url: location.href, | |
windowId: bcast.windowId, | |
sessionId, | |
isLast, | |
isOpen, | |
requestId, | |
now: localNow, | |
time: localNow - now | |
}); | |
bcast.sendMessage({command: 'messageResult', params: result}); | |
}; | |
const onWindowMessage = (cmd, type, sessionId) => { | |
const {command, params} = cmd; | |
const watchId = cmd.watchId || params.watchId; // 互換のため冗長 | |
if (watchId && command === 'open') { | |
if (config.props.enableSingleton) { | |
global.external.sendOrOpen(watchId); | |
} else { | |
player.open(watchId, {economy: Config.props.forceEconomy}); | |
} | |
} else if (watchId && command === 'send') { | |
BroadcastEmitter.sendExecCommand({command: 'openVideo', params: watchId}); | |
} | |
}; | |
BroadcastEmitter.on('message', (message, type, sessionId) => { | |
return type === 'broadcast' ? | |
onBroadcastMessage(message, type, sessionId) : | |
onWindowMessage(message, type, sessionId); | |
}); | |
player.on('close', () => BroadcastEmitter.notifyClose()); | |
player.on('open', () => BroadcastEmitter.notifyOpen()); | |
}; | |
const initializeExternal = dialog => { | |
const command = (command, param) => dialog.execCommand(command, param); | |
const open = (watchId, params) => dialog.open(watchId, params); | |
const send = (watchId, params) => BroadcastEmitter.sendOpen(watchId, params); | |
const sendOrOpen = (watchId, params) => { | |
if (dialog.isLastOpenedPlayer) { | |
open(watchId, params); | |
} else { | |
return BroadcastEmitter | |
.ping() | |
.then(() => send(watchId, params), () => open(watchId, params)); | |
} | |
}; | |
const importPlaylist = data => PlaylistSession.save(data); | |
const exportPlaylist = () => PlaylistSession.restore() || {}; | |
const sendExecCommand = (command, params) => BroadcastEmitter.sendExecCommand({command, params}); | |
const sendOrExecCommand = (command, params) => { | |
return BroadcastEmitter.ping() | |
.then(() => sendExecCommand(command, params), | |
() => dialog.execCommand(command, params)); | |
}; | |
const playlistAdd = watchId => sendOrExecCommand('playlistAdd', watchId); | |
const insertPlaylist = watchId => sendOrExecCommand('playlistInsert', watchId); | |
const deflistAdd = ({watchId, description, token}) => { | |
const mylistApiLoader = ZenzaWatch.api.MylistApiLoader; | |
if (token) { | |
mylistApiLoader.setCsrfToken(token); | |
} | |
return mylistApiLoader.addDeflistItem(watchId, description); | |
}; | |
const deflistRemove = ({watchId, token}) => { | |
const mylistApiLoader = ZenzaWatch.api.MylistApiLoader; | |
if (token) { | |
mylistApiLoader.setCsrfToken(token); | |
} | |
return mylistApiLoader.removeDeflistItem(watchId); | |
}; | |
const echo = (msg = 'こんにちはこんにちは!') => sendExecCommand('echo', msg); | |
Object.assign(ZenzaWatch.external, { | |
execCommand: command, | |
sendExecCommand, | |
sendOrExecCommand, | |
open, | |
send, | |
sendOrOpen, | |
deflistAdd, | |
deflistRemove, | |
hello: BroadcastEmitter.hello, | |
ping: BroadcastEmitter.ping, | |
echo, | |
playlist: { | |
add: playlistAdd, | |
insert: insertPlaylist, | |
import: importPlaylist, | |
export: exportPlaylist | |
} | |
}); | |
Object.assign(ZenzaWatch.debug, { | |
dialog, | |
getFrameBodies: () => { | |
return Array.from(document.querySelectorAll('.zenzaPlayerContainer iframe')).map(f => f.contentWindow.document.body); | |
} | |
}); | |
if (ZenzaWatch !== window.ZenzaWatch) { | |
window.ZenzaWatch.external = { | |
open, | |
sendOrOpen, | |
sendOrExecCommand, | |
hello: BroadcastEmitter.hello, | |
ping: BroadcastEmitter.ping, | |
echo, | |
playlist: { | |
add: playlistAdd, | |
insert: insertPlaylist | |
} | |
}; | |
} | |
}; | |
const initializeLastSession = dialog => { | |
window.addEventListener('beforeunload', () => { | |
if (!dialog.isOpen) { | |
return; | |
} | |
PlayerSession.save(dialog.playingStatus); | |
dialog.close(); | |
}, {passive: true}); | |
PlayerSession.init(sessionStorage); | |
let lastSession = PlayerSession.restore(); | |
let screenMode = Config.props.screenMode; | |
if ( | |
lastSession.playing && | |
(screenMode === 'small' || | |
screenMode === 'sideView' || | |
location.href === lastSession.url || | |
Config.props.continueNextPage | |
) | |
) { | |
lastSession.eventType = 'session'; | |
dialog.open(lastSession.watchId, lastSession); | |
} else { | |
PlayerSession.clear(); | |
} | |
}; | |
const initializeBySite = () => { | |
const hostClass = location.host | |
.replace(/^.*\.slack\.com$/, 'slack.com') | |
.replace(/\./g, '-'); | |
document.body.dataset.domain = hostClass; | |
util.StyleSwitcher.update({on: `style.domain.${hostClass}`}); | |
}; | |
const initializeDialogPlayer = (config, offScreenLayer) => { | |
console.log('initializeDialog'); | |
config = PlayerConfig.getInstance(config); | |
const state = PlayerState.getInstance(config); | |
ZenzaWatch.state.player = state; | |
const dialog = new NicoVideoPlayerDialog({ | |
offScreenLayer, | |
config, | |
state | |
}); | |
RootDispatcher.initialize(dialog); | |
return dialog; | |
}; | |
return {initialize}; | |
})(); | |
const CustomElements = {}; | |
CustomElements.initialize = (() => { | |
if (!window.customElements) { | |
return; | |
} | |
class PlaylistAppend extends HTMLElement { | |
static get observedAttributes() { return []; } | |
static template() { | |
return ` | |
<style> | |
* { | |
box-sizing: border-box; | |
user-select: none; | |
} | |
:host { | |
background: none !important; | |
border: none !important; | |
} | |
.playlistAppend { | |
display: inline-block; | |
font-size: 16px; | |
line-height: 22px; | |
width: 24px; | |
height: 24px; | |
background: #666; | |
color: #ccc; | |
text-decoration: none; | |
border: 1px outset; | |
border-radius: 3px; | |
cursor: pointer; | |
text-align: center; | |
} | |
.playlistAppend:active { | |
border: 1px inset; | |
} | |
.label { | |
text-shadow: 1px 1px #333; | |
display: inline-block; | |
} | |
:host-context(.videoList) .playlistAppend { | |
width: 24px; | |
height: 20px; | |
line-height: 18px; | |
border-radius: unset; | |
} | |
:host-context(.videoOwnerInfoContainer) { | |
} | |
</style> | |
<div class="playlistAppend"> | |
<div class="label">▶</div></div> | |
`; | |
} | |
constructor() { | |
super(); | |
const shadow = this._shadow = this.attachShadow({mode: 'open'}); | |
shadow.innerHTML = this.constructor.template(); | |
} | |
disconnectedCallback() { | |
this._shadow.textContent = ''; | |
} | |
} | |
window.customElements.define('zenza-playlist-append', PlaylistAppend); | |
class SeekbarLabel extends HTMLElement { | |
static get observedAttributes() { return [ | |
'time', 'duration', 'text' | |
]; } | |
static template() { | |
return ` | |
<style> | |
*, *::after, *::before { | |
box-sizing: border-box; | |
user-select: none; | |
} | |
:host(.owner-comment) * { | |
--color: #efa; | |
--pointer-color: rgba(128, 255, 128, 0.6); | |
} | |
.root * { | |
pointer-events: none; | |
} | |
.root { | |
position: absolute; | |
width: 16px; | |
height: 16px; | |
top: calc(100% - 2px); | |
left: 50%; | |
color: var(--color, #fea); | |
border-style: solid; | |
border-width: 8px; | |
border-color: | |
var(--pointer-color, rgba(255, 128, 128, 0.6)) | |
transparent | |
transparent | |
transparent; | |
} | |
.label { | |
display: inline-block; | |
visibility: hidden; | |
position: absolute; | |
left: -8px; | |
bottom: 8px; | |
white-space: nowrap; | |
padding: 2px 4px; | |
background: rgba(0, 0, 0, 0.8); | |
border-radius: 4px; | |
border-color: var(--pointer-color, rgba(255, 128, 128, 0.6)); | |
border-style: solid; | |
opacity: 0.5; | |
} | |
.root:hover .label { | |
visibility: visible; | |
} | |
</style> | |
<div class="root"> | |
<span class="label"></span> | |
</div> | |
`; | |
} | |
constructor() { | |
super(); | |
const shadow = this._shadow = this.attachShadow({mode: 'open'}); | |
shadow.innerHTML = this.constructor.template(); | |
this._root = shadow.querySelector('.root'); | |
this._label = shadow.querySelector('.label'); | |
this._updatePos = _.debounce(this._updatePos.bind(this), 100); | |
this.props = { | |
time: -1, | |
duration: 1, | |
text: this.getAttribute('text') || this.getAttribute('data-text') | |
}; | |
this._label.textContent = this.props.text; | |
} | |
_updateTime(t) { | |
this.props.time = isNaN(t) ? -1 : t; | |
this._updatePos(); | |
} | |
_updateDuration(d) { | |
this.props.duration = isNaN(d) ? 1 : d; | |
this._updatePos(); | |
} | |
_updatePos() { | |
const per = this.props.time / Math.max(this.props.duration, 1) * 100; | |
this.hidden = per <= 0; | |
this.setAttribute('data-param', this.props.time); | |
this._root.style.transform = `translate(${per}vw, 0) translateX(-50%) scale(var(--scale-pp, 1.2))`; | |
this._label.style.transform = `translate(-${per}%, 0)`; | |
} | |
_clear() { | |
this._root.classList.toggle('has-screenshot', false); | |
this.props.time = -1; | |
this.props.duration = 1; | |
this.hidden = true; | |
} | |
hide() { | |
this.hidden = true; | |
} | |
attributeChangedCallback(attr, oldValue, newValue) { | |
switch (attr) { | |
case 'time': | |
this._updateTime(parseFloat(newValue)); | |
break; | |
case 'duration': | |
this._updateDuration(parseFloat(newValue)); | |
break; | |
case 'text': | |
this._label.textContent = newValue; | |
break; | |
} | |
} | |
} | |
window.customElements.define('zenza-seekbar-label', SeekbarLabel); | |
}); | |
const TextLabel = (() => { | |
const func = function(self) { | |
const items = {}; | |
const getId = function() {return `id-${this.id++}${Math.random()}`;}.bind({id: 0}); | |
const create = async ({canvas, style}) => { | |
const id = getId(); | |
const ctx = canvas.getContext('2d', { | |
desynchronized: true, | |
}); | |
items[id] = { | |
canvas, style, ctx, text: '' | |
}; | |
return setStyle({id, style}); | |
}; | |
const setStyle = ({id, style, name}) => { | |
const item = items[id]; | |
if (!item) { throw new Error('unknown id', id); } | |
name = name || 'label'; | |
const {canvas, ctx} = item; | |
item.text = ''; | |
style.widthPx && (canvas.width = style.widthPx * style.ratio); | |
style.heightPx && (canvas.height = style.heightPx * style.ratio); | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
return {id, text: ''}; | |
}; | |
const drawText = ({id, text}) => { | |
const item = items[id]; | |
if (!item) { throw new Error('unknown id', id); } | |
const {canvas, ctx, style} = item; | |
if (item.text === text) { | |
return; | |
} | |
ctx.beginPath(); | |
ctx.font = `${style.fontWeight || ''} ${style.fontSizePx ? `${style.fontSizePx * style.ratio}px` : ''} ${style.fontFamily || ''}`.trim(); | |
const measured = ctx.measureText(text); | |
let {width, height} = measured; | |
height = (height || style.fontSizePx) * style.ratio; | |
const left = (canvas.width - width) / 2; | |
const top = canvas.height - (canvas.height - height) / 2; | |
ctx.fillStyle = style.color; | |
ctx.textAlign = style.textAlign; | |
ctx.textBaseline = 'bottom'; | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
ctx.fillText(text, left, top); | |
return {id, text}; | |
}; | |
const dispose = ({id}) => { | |
delete items[id]; | |
}; | |
self.onmessage = async ({command, params}) => { | |
switch (command) { | |
case 'create': | |
return create(params); | |
case 'style': | |
return setStyle(params); | |
case 'drawText': | |
return drawText(params); | |
case 'dispose': | |
return dispose(params); | |
} | |
}; | |
}; | |
const isOffscreenCanvasAvailable = !!HTMLCanvasElement.prototype.transferControlToOffscreen; | |
const getContainerStyle = ({container, canvas, ratio}) => { | |
let style = window.getComputedStyle(container || document.body); | |
ratio = ratio || window.devicePixelRatio; | |
const width = (container.offsetWidth || canvas.width) * ratio; | |
const height = (container.offsetHeight || canvas.height) * ratio; | |
if (!width || !height) { | |
style = window.getComputedStyle(document.body); | |
} | |
return { | |
width, | |
height, | |
font: style.font, | |
fontFamily: style.fontFamily, | |
fontWeight: style.fontWeight, | |
fontSizePx: style.fontSize.replace(/[a-z]/g, '') * 1, | |
color: style.color, | |
backgroundColor: style.backgroundColor, | |
textAlign: style.textAlign, | |
ratio | |
}; | |
}; | |
const NAME = 'TextLabelWorker'; | |
let worker; | |
const initWorker = async () => { | |
if (worker) { return worker; } | |
if (!isOffscreenCanvasAvailable) { | |
if (!worker) { | |
worker = { | |
name: NAME, | |
onmessage: () => {}, | |
post: ({command, params}) => worker.onmessage({command, params}) | |
}; | |
func(worker); | |
} | |
} else { | |
worker = worker || workerUtil.createCrossMessageWorker(func, {name: NAME}); | |
} | |
return worker; | |
}; | |
const create = ({container, canvas, ratio, name, style, text}) => { | |
style = style || {}; | |
ratio = Math.max(ratio || window.devicePixelRatio || 2, 2); | |
style.ratio = style.ratio || ratio; | |
name = name || 'label'; | |
if (!canvas) { | |
canvas = document.createElement('canvas'); | |
Object.assign(canvas.style, { | |
width: `${style.widthPx}px` || '100%', | |
height: `${style.heightPx}px` || '100%', | |
backgroundColor: style.backgroundColor || '' | |
}); | |
container && container.append(canvas); | |
style.widthPx && (canvas.width = Math.max(style.widthPx * ratio)); | |
style.heightPx && (canvas.height = Math.max(style.heightPx * ratio)); | |
} | |
canvas.dataset.name = name; | |
const containerStyle = getContainerStyle({container, canvas, ratio}); | |
style.fontFamily = style.fontFamily || containerStyle.fontFamily; | |
style.fontWeight = style.fontWeight || containerStyle.fontWeight; | |
style.color = style.color || containerStyle.color; | |
const promiseSetup = (async () => { | |
const layer = isOffscreenCanvasAvailable ? canvas.transferControlToOffscreen() : canvas; | |
const worker = await initWorker(); | |
const result = await worker.post( | |
{command: 'create', params: {canvas: layer, style, name}}, | |
{transfer: [layer]} | |
); | |
return result.id; | |
})(); | |
const init = {text}; | |
const post = async ({command, params}, transfer = {}) => { | |
const id = await promiseSetup; | |
params = params || {}; | |
params.id = id; | |
return worker.post({command, params}, transfer); | |
}; | |
const result = { | |
container, | |
canvas, | |
style() { | |
init.text = ''; | |
const style = getContainerStyle({container, canvas}); | |
return post({command: 'style', params: {style, name}}); | |
}, | |
async drawText(text) { | |
if (init.text === text) { | |
return; | |
} | |
const result = await post({command: 'drawText', params: {text}}); | |
init.text = result.text; | |
}, | |
get text() { return init.text; }, | |
set text(t) { this.drawText(t); }, | |
dispose: () => worker.post({command: 'dispose'}) | |
}; | |
text && (result.text = text); | |
return result; | |
}; | |
return {create}; | |
})(); | |
ZenzaWatch.modules.TextLabel = TextLabel; | |
if (window.name === 'commentLayerFrame') { | |
return; | |
} | |
if (location.host === 'www.nicovideo.jp') { | |
return initialize(); | |
} | |
uq.ready().then(() => NicoVideoApi.configBridge(Config)).then(() => { | |
window.console.log('%cZenzaWatch Bridge: %s', 'background: lightgreen;', location.host); | |
if (document.getElementById('siteHeaderNotification')) { | |
return initialize(); | |
} | |
NicoVideoApi.fetch('https://www.nicovideo.jp/',{credentials: 'include'}) | |
.then(r => r.text()) | |
.then(result => { | |
const $dom = util.$(`<div>${result}</div>`); | |
const userData = JSON.parse($dom.find('#CommonHeader')[0].dataset.commonHeader).initConfig.user; | |
const isLogin = !!userData.isLogin; | |
const isPremium = !!userData.isPremium; | |
window.console.log('isLogin: %s isPremium: %s', isLogin, isPremium); | |
nicoUtil.isLogin = () => isLogin; | |
nicoUtil.isPremium = util.isPremium = () => isPremium; | |
initialize(); | |
}); | |
}, err => window.console.log('ZenzaWatch Bridge disabled', err)); | |
}; // end of monkey | |
(() => { | |
const dimport = (() => { | |
try { // google先生の真似 | |
return new Function('u', 'return import(u)'); | |
} catch(e) { | |
const map = {}; | |
let count = 0; | |
return url => { | |
if (map[url]) { | |
return map[url]; | |
} | |
try { | |
const now = Date.now(); | |
const callbackName = `dimport_${now}_${count++}`; | |
const loader = ` | |
import * as module${now} from "${url}"; | |
console.log('%cdynamic import from "${url}"', | |
'font-weight: bold; background: #333; color: #ff9; display: block; padding: 4px; width: 100%;'); | |
window.${callbackName}(module${now}); | |
`.trim(); | |
window.console.time(`"${url}" import time`); | |
const p = new Promise((ok, ng) => { | |
const s = document.createElement('script'); | |
s.type = 'module'; | |
s.onerror = ng; | |
s.append(loader); | |
s.dataset.import = url; | |
window[callbackName] = module => { | |
window.console.timeEnd(`"${url}" import time`); | |
ok(module); | |
delete window[callbackName]; | |
}; | |
document.head.append(s); | |
}); | |
map[url] = p; | |
return p; | |
} catch (e) { | |
console.warn(url, e); | |
return Promise.reject(e); | |
} | |
}; | |
} | |
})(); | |
function EmitterInitFunc() { | |
class Handler { //extends Array { | |
constructor(...args) { | |
this._list = args; | |
} | |
get length() { | |
return this._list.length; | |
} | |
exec(...args) { | |
if (!this._list.length) { | |
return; | |
} else if (this._list.length === 1) { | |
this._list[0](...args); | |
return; | |
} | |
for (let i = this._list.length - 1; i >= 0; i--) { | |
this._list[i](...args); | |
} | |
} | |
execMethod(name, ...args) { | |
if (!this._list.length) { | |
return; | |
} else if (this._list.length === 1) { | |
this._list[0][name](...args); | |
return; | |
} | |
for (let i = this._list.length - 1; i >= 0; i--) { | |
this._list[i][name](...args); | |
} | |
} | |
add(member) { | |
if (this._list.includes(member)) { | |
return this; | |
} | |
this._list.unshift(member); | |
return this; | |
} | |
remove(member) { | |
this._list = this._list.filter(m => m !== member); | |
return this; | |
} | |
clear() { | |
this._list.length = 0; | |
return this; | |
} | |
get isEmpty() { | |
return this._list.length < 1; | |
} | |
*[Symbol.iterator]() { | |
const list = this._list || []; | |
for (const member of list) { | |
yield member; | |
} | |
} | |
next() { | |
return this[Symbol.iterator](); | |
} | |
} | |
Handler.nop = () => {/* ( ˘ω˘ ) スヤァ */}; | |
const PromiseHandler = (() => { | |
const id = function() { return `Promise${this.id++}`; }.bind({id: 0}); | |
class PromiseHandler extends Promise { | |
constructor(callback = () => {}) { | |
const key = new Object({id: id(), callback, status: 'pending'}); | |
const cb = function(res, rej) { | |
const resolve = (...args) => { this.status = 'resolved'; this.value = args; res(...args); }; | |
const reject = (...args) => { this.status = 'rejected'; this.value = args; rej(...args); }; | |
if (this.result) { | |
return this.result.then(resolve, reject); | |
} | |
Object.assign(this, {resolve, reject}); | |
return callback(resolve, reject); | |
}.bind(key); | |
super(cb); | |
this.resolve = this.resolve.bind(this); | |
this.reject = this.reject.bind(this); | |
this.key = key; | |
} | |
resolve(...args) { | |
if (this.key.resolve) { | |
this.key.resolve(...args); | |
} else { | |
this.key.result = Promise.resolve(...args); | |
} | |
return this; | |
} | |
reject(...args) { | |
if (this.key.reject) { | |
this.key.reject(...args); | |
} else { | |
this.key.result = Promise.reject(...args); | |
} | |
return this; | |
} | |
addCallback(callback) { | |
Promise.resolve().then(() => callback(this.resolve, this.reject)); | |
return this; | |
} | |
} | |
return PromiseHandler; | |
})(); | |
const {Emitter} = (() => { | |
let totalCount = 0; | |
let warnings = []; | |
class Emitter { | |
on(name, callback) { | |
if (!this._events) { | |
Emitter.totalCount++; | |
this._events = new Map(); | |
} | |
name = name.toLowerCase(); | |
let e = this._events.get(name); | |
if (!e) { | |
const handler = new Handler(callback); | |
handler.name = name; | |
e = this._events.set(name, handler); | |
} else { | |
e.add(callback); | |
} | |
if (e.length > 10) { | |
console.warn('listener count > 10', name, e, callback); | |
!Emitter.warnings.includes(this) && Emitter.warnings.push(this); | |
} | |
return this; | |
} | |
off(name, callback) { | |
if (!this._events) { | |
return; | |
} | |
name = name.toLowerCase(); | |
const e = this._events.get(name); | |
if (!this._events. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment